37 Commits
Author SHA1 Message Date
tmu b5bfe83140 🚀 Release 0.1.1
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 34s
CI / maintain (push) Failing after 27s
CI / publish (push) Failing after 4s
2026-09-14 11:58:51 +00:00
tmu 60bf1bb3a9 🔀 Merge chore/dependency-updates into main 2026-09-14 11:58:11 +00:00
tmu 24bd0270c0 ⬆️ Upgrade dependencies 2026-09-14 11:57:34 +00:00
tmu 475136c1e5 📝 Check off done tasks 2026-09-14 11:55:28 +00:00
tmu 67e3e13b5e 🚀 Release 0.1.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 37s
CI / maintain (push) Failing after 30s
CI / publish (push) Failing after 4s
2026-09-14 11:48:55 +00:00
tmu 595759ca7a 🔀 Merge feature/setup into main 2026-09-14 11:47:58 +00:00
tmu 8495adc47b 👷 Retitle release commits to 🚀
The release commit message is a machine-read convention (release-gate
in ci.yml skips the redundant main CI run on it), so the emoji carries
weight: change the mint in scripts/release.sh, the recognized pattern,
and both docs in one atomic commit. No tags or release commits exist
yet, so nothing historical parses differently. 🚀 matches the
gitmoji semantic (deploy/publish) better than 🔖 here.
2026-09-14 11:47:00 +00:00
tmu 3df672d306 👷 Skip main CI run for release commits
A release pushes main and then a tag pointing at the same commit, so
the branch run re-verifies the identical SHA the tag run already
verifies (and publishes). Add a cheap release-gate job that recognizes
the '🔖 Release x.y.z' commit message on main and skips the
full build/maintain jobs; tag, PR, and ordinary main pushes are
unaffected, and the gate fails open (runs CI) if it errors.
2026-09-14 11:41:49 +00:00
tmu 42cbe2194e ♻️ Finalize changelog notes before pubv
pubv's bump heuristic reads [Unreleased], so the maintainer must write the
notes before pubv runs, not after. pubv refuses a dirty tree (its prompt
defaults to No), so the edit is committed as a staging commit and folded back
into pubv's single release commit.
2026-09-14 11:16:47 +00:00
tmu 9dd973a950 🐛 Refresh origin/HEAD before pubv preflight
pubv resolves the default branch from the local refs/remotes/origin/HEAD,
which git fetch never updates, so a clone or default-branch change left it
stale and pubv warned that main was not the default. Refresh it from the
remote before pubv runs, and assert releases are cut from main explicitly.
2026-09-14 11:08:59 +00:00
tmu 034296f011 ✨ Add create:finish merge front door
The merge half of the branching model was still prose, so it drifted per
session. create:finish mirrors create:branch: it asserts the merge-side
preconditions, fast-forwards a stale main (divergence is refused), merges
--no-ff, runs npm run verify, and deletes the branch only when green. The
push stays with create:release so the merge is reviewable first.
2026-09-14 11:08:59 +00:00
tmu 78bec7a5eb ✨ Emit precompressed sidecars for served assets
Add scripts/precompress.ts, a dependency-free Node 26 tool that writes
.br/.gz/.zst sidecars next to every text asset and keeps the originals, so
static-web-server can serve the variant matching Accept-Encoding and fall
back to the original. The coverage publish step runs it on the copied report.

Wire scripts/*.ts into the toolchain: type-check (tsconfig include), lint and
fix (oxlint paths), knip entry (CI invokes the file rather than importing it),
and a narrow .oxlintrc override allowing node:* imports in Node CLI scripts.
2026-09-13 20:46:59 +00:00
tmu 224afa9afb 👷 Publish tag coverage to the pages server
The build job bind-mounts the shared pages tree (the runner whitelists it
via container.valid_volumes) and, on tag pushes, wipes
/data/gitea-pages/<owner>/<repo>/<tag>/coverage before copying the c8 report
into it. Only this tag's coverage/ is touched; older tags and sibling
docs/landing trees are left for manual pruning.

Add a `tags: ["*"]` push trigger: a `branches` filter alone matches no tag
ref, so the tag-gated publish job (and this coverage step) could never run.

Track per-branch coverage as a backlog task.
2026-09-13 20:38:59 +00:00
tmu a865b466bd 👷 Add Gitea release page to CI
The publish job now opens a Gitea release for the tag, using the
matching Keep-a-Changelog section as the body via scripts/release-notes.sh.
It runs before npm publish so a broken page fails CI without burning an
npm version; npm publish stays the last step.

Uses the first-party gitea-release-action (the older actions/release-action
is deprecated and requires asset files) and contents: write for the run's
automatic token.
2026-09-11 22:06:05 +00:00
tmu 7216c418d0 📝 Add a few new ideas to backlog 2026-09-11 20:54:16 +00:00
tmu 1191f3c4d9 📝 Prune the backlog of closed tasks
All completed and cancelled setup-phase entries are resolved and reflected
in README/CONTRIBUTING/ci.yml; the only still-open items remain.
2026-09-11 20:30:16 +00:00
tmu eee73a151d 📝 Point feedback tiers at ci.yml as the source of truth
The CI build row and the publishing workflow restated a step list that
had already drifted from the workflow (build + publint in build, publish
consuming the build artifact). Reference .gitea/workflows/ci.yml instead
of duplicating the job graph.
2026-09-11 20:23:36 +00:00
tmu 4d92ce9f9a 📝 Check off dist-output v1.0 tasks
The emit work (rewriteRelativeImportExtensions, inlineSources, prebuild
clean) resolved the v1.0 dist/ output block, and attw is covered by the
CI publish tier rather than a standalone checklist item.
2026-09-11 20:23:32 +00:00
tmu b29f924416 🔧 Clean dist before each build
tsc does not prune orphaned emit output, so dropping declarationMap left
stale *.d.ts.map files in dist/ until a manual clean. A prebuild hook now
empties dist/ first, keeping the build reproducible. It deliberately leaves
coverage/ alone, since the manual `clean` resets both.
2026-09-11 20:04:33 +00:00
tmu a0dd187042 📝 Document the TypeScript 5.0 type floor
Consumers need TypeScript >= 5.0: the emitted declarations use const type
parameters and keep relative .ts specifiers, both of which resolve only on
TS >= 5.0. That also makes the emitted .ts specifiers a non-issue, so the
backlog item is closed without a .d.ts post-step: TS <= 4.9 cannot parse
the declarations anyway. The README/CONTRIBUTING wording now says the
rewrite applies to the JavaScript output, not to the declarations.
2026-09-11 19:58:36 +00:00
tmu e7a058d608 🔧 Inline sources and drop declaration maps
The JS source maps now embed their sources (inlineSources), so a debugger
resolves into src/ even though the package ships only dist/. declarationMap
is dropped: a .d.ts.map cannot embed source and would dangle against the
unshipped src/.

Also removes a duplicate backlog entry for the same task and closes the
sourcemap item.
2026-09-11 19:50:50 +00:00
tmu e96c3f89ac 📝 Hand over the two open emit items
Record what a following agent cannot cheaply reconstruct for the
remaining setup-phase items (.d.ts .ts extensions, sourcemap sources):
the verified current emit state, the fact that every existing gate is
already green so no red signal will confirm the fix, that the durable
proof is a consumer-resolution typecheck rather than exit 0, and that
the two items share the emit surface so they belong in one change.
2026-09-10 23:53:46 +00:00
tmu 78aa97d670 📝 Correct the pi-lsp false-positive finding
The TS1295/TS1287 errors were not an LSP misconfiguration: the server
was still running from an earlier local test that had emptied
package.json, and kept the corrupted project state alive. Driven with
pi-lsp's exact handshake, tsc --lsp --stdio reads tsconfig.json
correctly. Record the real caveat instead: no file watchers, so
tsconfig.json/package.json edits can leave diagnostics stale until the
server restarts.
2026-09-10 23:49:09 +00:00
tmu 13ea134d05 📝 Document the .pi extension install model
.pi/settings.json is the shared, committed declaration and .pi/npm/ is
a gitignored cache pi recreates on a trusted startup, so the generated
.gitignore is intentional and nothing needs tracking. Record this in
the README tooling notes and close the reproducibility item as
resolved by design.
2026-09-10 23:37:23 +00:00
tmu b18e03ff97 📝 Keep verify free of the emit path
verify is the local quick source-correctness check; the emit path is
already gated by CI's build job, so adding build to verify would only
add inner-loop friction for something CI covers.
2026-09-10 23:31:45 +00:00
tmu 0ceb5a586e 📝 Confirm pre-push stays a local quick check
Pre-push runs npm test only. That is intended: the staged-file checks
are pre-commit's job and whole-project correctness is CI's, which
checks the pushed commits' content. Dirty working-tree files are out
of scope for a local check that CI backs up, so no dirty-tree guard is
added.
2026-09-10 23:25:04 +00:00
tmu fe9d59f8d4 👷 Publish the dist built by the build job
The publish job re-ran npm ci + build from scratch, discarding the
output that check, test:ci and publint had already gated, and paying
the build twice on a tag push. Upload dist/ as an artifact from build
and download it in publish instead. npm ci stays for the publint/attw
binaries; only the redundant rebuild is gone.
2026-09-10 23:05:46 +00:00
tmu 303544796e 👷 Run publint in the CI build job
publish:publint and publish:attw only ran in the tag-triggered publish
job, so a packaging break stayed green until release. Add publint to
build (offline, fast, packs the built dist). attw stays in publish,
where the full resolution matrix is worth the cost.
2026-09-10 23:03:14 +00:00
tmu 968a478c93 ♻️ Use rm for the clean target
The node -e rmSync one-liner was carried over from the build-tool
switch and only existed for cross-platform shell safety. The repo is
POSIX-only (sh scripts, Node 26, Linux CI), so rm -rf is simpler and
consistent with the rest of the tooling.
2026-09-10 22:56:12 +00:00
tmu e86709f593 📝 Sweep doc and formatter drift
README named --experimental-strip-types, which was renamed to
--strip-types. Document the current flag only.

The extension recommendations list is unchanged, but the VSCode section
undercounted it and now matches extensions.json. The per-language
formatter blocks in .vscode/settings.json stay (they keep a user's
local formatter settings from overriding the project's) and gain the
JSX/TSX language ids to match what oxfmt and the lefthook glob cover.
2026-09-10 22:49:01 +00:00
tmu 1709fbe842 🔧 Ignore pubv in knip via knip.json
pubv is a CLI invoked by release.sh, never imported, so knip reported
it as an unused devDependency on every advisory run. Add a knip config
with an ignoreDependencies entry; verified removal of the entry
reproduces the report.
2026-09-10 22:43:04 +00:00
tmu 0b461c233f 🔥 Drop redundant main/types fields
exports already declares types + import for the root entry, so the
top-level main/types pair was duplicate metadata. publint and attw stay
green with exports alone, verified against a packed consumer install.
2026-09-10 22:42:32 +00:00
tmu 8d1a03f025 🔧 Remove coverage in clean target
clean only wiped dist, leaving the gitignored coverage/ tree to
accumulate across runs. Remove both so a clean checkout state is
actually reachable.
2026-09-10 22:42:14 +00:00
tmu b7d1dbdf06 📝 Track setup-phase review findings in the backlog
Record the tooling, CI and docs gaps found reviewing the setup phase
(src/ is placeholder code and was excluded from the review).

The entries cover pi-lsp install reproducibility, gating the emit path
in verify, .ts extensions left in emitted .d.ts, pi-lsp false-positive
diagnostics, CI packaging checks and artifact reuse, pre-push gate
strength, plus low-impact cleanups (clean target, redundant fields,
knip false positive, sourcemap sources, doc drift).
2026-09-10 22:37:59 +00:00
tmu 99bda289a6 📝 Document .editorconfig as oxfmt fallback
Recommend `EditorConfig.EditorConfig` in .vscode/extensions.json so editors
without native `.editorconfig` support pick it up, and add an "Editor
configuration" section to CONTRIBUTING.md: `.editorconfig` is an
editor-compatibility fallback for the file types oxfmt does not format
(shell scripts, dotfiles, the commit-message template, git's
`COMMIT_EDITMSG` buffer), with `.oxfmtrc.json` authoritative where both
apply. `.editorconfig` itself is unchanged.
2026-09-10 21:47:08 +00:00
tmu 0c6d1483de ♻️ Port CI workflow from GitHub Actions to Gitea Actions
The runner now lives on Gitea; move the workflow to .gitea/workflows/ and
delete the stale .github copy (Gitea ignores .github/, so it's drift bait).
Use the native gitea.ref context for the tag gate; keep actions pinned to
their GitHub sources (the runner has internet + caches them). Drop the
upload-artifact coverage step in favour of the self-hosted webserver plan
tracked in the backlog. Add an empty-secret gate as publish's first step so
a tag push without NPM_TOKEN fails loudly instead of silently no-oppping.
2026-09-10 19:41:09 +02:00
tmu 9a08262076 📝 Track self-hosted coverage hosting plan in backlog
Record the decision to host CI coverage from a tiny dir-listing server in
the Gitea docker setup (shared volume keyed by repo/tag) instead of the
GitHub upload-artifact zip. This unblocks dropping the coverage artifact
from the Gitea CI port. Adds the lipanski docker-image author to cspell.
2026-09-10 19:40:35 +02:00
21 changed files with 983 additions and 444 deletions

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
name: CI
on:
push:
branches: [main]
# Releases are tag pushes (`scripts/release.sh` tags bare `x.y.z`). A
# `branches` filter alone matches no tag ref, so without this both the
# tag-gated `publish` job and the coverage publish step never fire.
tags: ["*"]
pull_request:
branches: [main]
workflow_dispatch: {}
jobs:
# Cheap gate that collapses the release double-run. `scripts/release.sh`
# pushes `main` and the tag seconds apart, and the tag points at exactly
# the HEAD commit that push delivers — so the branch run would verify the
# identical tree the tag run verifies anyway (plus `publish`). When a push
# to `main` is headed by a release commit (`:rocket: Release x.y.z`, the
# single commit release.sh creates), the full CI is skipped here and the
# tag run becomes the authoritative one for that SHA. All other pushes —
# PRs, tags, ordinary `main` merges — see `skip=false` and run as before.
#
# Coupling: the pattern below MUST stay in sync with the release commit
# message in `scripts/release.sh`. Failure mode if the tag push ever fails
# after `main` accepted the release commit: no CI fires; fix by re-running
# `git push --tags`.
release-gate:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.decide.outputs.skip }}
steps:
- uses: actions/checkout@v4
- id: decide
env:
REF: ${{ gitea.ref }}
run: |
# Keyed on the ref, not just the message: a tag run checks out
# the same release commit, and `publish` needs its `build`.
if [ "${REF}" = "refs/heads/main" ] &&
git log -1 --format=%s | grep -qE '^:rocket: Release [0-9]+\.[0-9]+\.[0-9]+$'; then
echo 'Release commit on main — the tag run covers this SHA; skipping full CI.'
echo 'skip=true' >>"${GITHUB_OUTPUT}"
else
echo 'skip=false' >>"${GITHUB_OUTPUT}"
fi
build:
needs: release-gate
if: needs.release-gate.outputs.skip != 'true'
runs-on: ubuntu-latest
# Bind-mount the shared pages tree so the coverage step below can write
# into it. The runner whitelists this path via `container.valid_volumes`
# (docker-space `setup/gitea.sh`); `image` is omitted on purpose so the
# runner keeps using its default job image.
container:
volumes:
- /data/gitea-pages:/data/gitea-pages
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: "npm"
- run: npm ci
- run: npm run build
- run: npm run check
- run: npm run test:ci
# Publish this tag's coverage to the self-hosted pages server,
# served read-only at
# https://pages.e1nsnull.de/<owner>/<repo>/<tag>/coverage/. Wipe only
# this tag's `coverage/`, so sibling docs/landing trees and older
# tags survive; pruning stale tags is a manual chore. The
# precompress pass emits `.br` / `.gz` / `.zst` sidecars next to
# every text asset, so `static-web-server` can serve the precompressed
# variant and keep the original as fallback.
- name: Publish coverage to the pages server
if: startsWith(gitea.ref, 'refs/tags/')
env:
REPO: ${{ github.repository }}
REF: ${{ gitea.ref }}
run: |
TAG="${REF#refs/tags/}"
DEST="/data/gitea-pages/${REPO}/${TAG}/coverage"
rm -rf "${DEST}"
mkdir -p "${DEST}"
cp -R coverage/. "${DEST}/"
node --strip-types scripts/precompress.ts "${DEST}"
echo "Coverage: https://pages.e1nsnull.de/${REPO}/${TAG}/coverage/"
# Fast, offline packaging gate. `attw` stays in `publish` (it needs
# a pack + full resolution matrix); `publint` packs too but is cheap
# enough to run on every push so a packaging break fails here, not
# at release time.
- run: npm run publish:publint
# Persist the exact dist/ that `check`, `test:ci` and `publint` were
# run against, so `publish` ships those bytes instead of rebuilding
# (which could in principle differ and would pay the build twice).
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
# Advisory scans (dead code, dependency freshness). Non-blocking: surfaced in
# the Actions tab for visibility, but must never gate a merge — so
# continue-on-error and intentionally NOT in `publish`'s `needs`.
maintain:
needs: release-gate
if: needs.release-gate.outputs.skip != 'true'
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: "npm"
- run: npm ci
- run: npm run maintain
publish:
if: startsWith(gitea.ref, 'refs/tags/')
needs: build
runs-on: ubuntu-latest
# The release page is created with the run's automatic Gitea token
# (`github.token`), not `NPM_TOKEN`, so it needs `contents: write`.
permissions:
contents: write
steps:
# Double-gate: publish only runs on a tag *and* aborts here if NPM_TOKEN
# is unset, so a tag push never silently no-ops (or half-publishes). Set
# NPM_TOKEN in the Gitea repo: Settings → Actions → Secrets.
- name: Assert NPM_TOKEN is configured
run: |
if [ -z "${{ secrets.NPM_TOKEN }}" ]; then
echo "::error::NPM_TOKEN secret is not set — refusing to publish."
exit 1
fi
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
registry-url: "https://registry.npmjs.org/"
- run: npm ci
# Consume the dist/ that `build` produced and gated, instead of
# rebuilding here — `publish` must ship the tested artifact.
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: npm run publish:publint
- run: npm run publish:attw
# The Gitea release page is created *before* `npm publish` on
# purpose: a broken page then fails CI without burning an npm
# version. The page is cheap to retry, a published version is not.
# The body is the matching Keep-a-Changelog section; an unknown tag
# makes the extractor exit non-zero, so the page can never go up
# empty.
- name: Extract release notes from CHANGELOG.md
env:
TAG_REF: ${{ gitea.ref }}
run: ./scripts/release-notes.sh "${TAG_REF#refs/tags/}" > release-notes.md
- uses: https://gitea.com/actions/gitea-release-action@v1
with:
body_path: release-notes.md
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-60
View File
@@ -1,60 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch: {}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: "npm"
- run: npm ci
- run: npm run build
- run: npm run check
- run: npm run test:ci
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage
# Advisory scans (dead code, dependency freshness). Non-blocking: surfaced on
# the PR for visibility, but must never gate a merge — so continue-on-error and
# intentionally NOT in `publish`'s `needs`.
maintain:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: "npm"
- run: npm ci
- run: npm run maintain
publish:
if: startsWith(github.ref, 'refs/tags/')
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
registry-url: "https://registry.npmjs.org/"
- run: npm ci
- run: npm run build
- run: npm run publish:publint
- run: npm run publish:attw
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+6
View File
@@ -33,6 +33,12 @@
"import/no-nodejs-modules": "off", "import/no-nodejs-modules": "off",
"eslint/no-magic-numbers": "off" "eslint/no-magic-numbers": "off"
} }
},
{
"files": ["scripts/**/*.ts"],
"rules": {
"import/no-nodejs-modules": "off"
}
} }
], ],
"ignorePatterns": ["dist", "node_modules", "coverage"] "ignorePatterns": ["dist", "node_modules", "coverage"]
+2 -1
View File
@@ -3,6 +3,7 @@
"oxc.oxc-vscode", "oxc.oxc-vscode",
"streetsidesoftware.code-spell-checker", "streetsidesoftware.code-spell-checker",
"typescriptteam.native-preview", "typescriptteam.native-preview",
"sandy081.todotasks" "sandy081.todotasks",
"EditorConfig.EditorConfig"
] ]
} }
+8
View File
@@ -3,10 +3,18 @@
"editor.defaultFormatter": "oxc.oxc-vscode", "editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"[typescriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "oxc.oxc-vscode", "editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"[javascriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[json]": { "[json]": {
"editor.defaultFormatter": "oxc.oxc-vscode", "editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
+2 -2
View File
@@ -9,7 +9,7 @@ first-action facts. Do not restate evolving prose here — it will drift.
- Project: F#-style pattern matching for TypeScript/ESM. Node `>=26` (pinned via `.node-version`), ESM-only (no CommonJS shim). - Project: F#-style pattern matching for TypeScript/ESM. Node `>=26` (pinned via `.node-version`), ESM-only (no CommonJS shim).
- **While iterating:** `npm run test` (`check:tsc` + the unit suite) for fast feedback on the files you changed. - **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)`. Prefer `lsp_references` over `grep -w` for widely-colliding identifiers (`matches`, `type`, …); use `lsp_hover` to read inferred types on generic-heavy code. It has no rename / code-action / apply-edit surface — the write side is pi's `edit` tool + `check:tsc`. Treat empty LSP output as _inconclusive_, not success: **`npm run test` / `npm run verify` remain the sole authoritative gate** (see the next bullet). - **Optional code intelligence:** this repo installs `@spences10/pi-lsp` (pinned in `.pi/settings.json`) as a project-local pi extension. It talks to the repo's own TypeScript 7 via `tsc --lsp --stdio` and exposes **read-only** tools — `lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_find_symbol`, `lsp_document_symbols`, `lsp_diagnostics(_many)`. Prefer `lsp_references` over `grep -w` for widely-colliding identifiers (`matches`, `type`, …); use `lsp_hover` to read inferred types on generic-heavy code. It has no rename / code-action / apply-edit surface — the write side is pi's `edit` tool + `check:tsc`. Treat empty LSP output as _inconclusive_, not success: **`npm run test` / `npm run verify` remain the sole authoritative gate** (see the next bullet). The server keeps running across that gate with a ~5 min idle timeout and registers no file watchers, so if you change `tsconfig.json` / `package.json` mid-session its diagnostics can be stale — when LSP output disagrees with `check:tsc`, trust `check:tsc` and restart pi (or wait out the idle timeout) before concluding the LSP is wrong.
- **Definition of done — run this before you call the work finished:** `npm run verify`. If all green, commit. If red, look at the output, fix the root cause, and re-run. - **Definition of done — run this before you call the work finished:** `npm run verify`. If all green, commit. If red, look at the output, fix the root cause, and re-run.
- **On commit:** write a good message (see [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages)). Lefthook's pre-commit hook already runs the fast, offline, staged-file checks — don't run them by hand. If the hook fails on style, `npm run fix`, restage, recommit. - **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.
- **`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. - **`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.
@@ -51,7 +51,7 @@ Never start a long-lived / blocking process such as `npm run watch`. It runs unt
**Known problems:** <open issues, caveats, follow-ups> **Known problems:** <open issues, caveats, follow-ups>
``` ```
Once the user has no further objections, merge back: `git checkout main && git merge --no-ff <branch>`. The branching model is documented in [CONTRIBUTING.md § Branching model](./CONTRIBUTING.md#branching-model). Once the user has no further objections, merge back: `npm run create:finish` (on the branch — it merges `--no-ff`, runs `npm run verify`, and deletes the branch). The branching model is documented in [CONTRIBUTING.md § Branching model](./CONTRIBUTING.md#branching-model).
- **Leaf task** (no indented children): implement on the current branch and commit. - **Leaf task** (no indented children): implement on the current branch and commit.
+11 -1
View File
@@ -7,4 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts ## [0.1.1] - 2026-09-14
- upgrade dependencies
## [0.1.0] - 2026-09-14
- basic setup
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.1...main
[0.1.1]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.0...0.1.1
[0.1.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/20308c5a6d8cccfb09b02ac2ebebd8055e91cd11...0.1.0
+15 -10
View File
@@ -6,13 +6,18 @@ This document is for maintainers and contributors working on the project itself.
CI and review will bounce these even though `npm run check` and the linters don't catch them. They're the high-frequency things a contributor (or an agent) reaches for by default: CI and review will bounce these even though `npm run check` and the linters don't catch them. They're the high-frequency things a contributor (or an agent) reaches for by default:
- **Source imports use `.ts` extensions, never `.js`.** `node --strip-types` resolves the `.ts` form at test time; `rewriteRelativeImportExtensions` emits `.js` in `dist/`. "Pre-fixing" an import to `.js` breaks the inner loop. (rationale: README § Tooling decisions) - **Source imports use `.ts` extensions, never `.js`.** `node --strip-types` only resolves the `.ts` form at test time; "Pre-fixing" an import to `.js` breaks the inner loop. (rationale: README § Tooling decisions)
- **A new `npm run` script must reuse an existing prefix** (`create:` / `check:` / `fix:` / `test:` / `watch:` / `maintain:` / `publish:` / `setup:`). If none fits, that's a signal the script doesn't belong in the pipeline — not a reason to invent a new prefix. If it genuinely does belong, add the prefix to both lists here in the same commit; an undocumented prefix becomes invisible and quietly accrues members. (see [Script prefix convention](#script-prefix-convention)) - **A new `npm run` script must reuse an existing prefix** (`create:` / `check:` / `fix:` / `test:` / `watch:` / `maintain:` / `publish:` / `setup:`). If none fits, that's a signal the script doesn't belong in the pipeline — not a reason to invent a new prefix. If it genuinely does belong, add the prefix to both lists here in the same commit; an undocumented prefix becomes invisible and quietly accrues members. (see [Script prefix convention](#script-prefix-convention))
- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The trade-off must sit next to the code it silences. This is a _human_ last-resort convention; agents must not add these — see [AGENTS.md § Never do](./AGENTS.md#never-do). (rationale: README § Tooling decisions) - **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The trade-off must sit next to the code it silences. This is a _human_ last-resort convention; agents must not add these — see [AGENTS.md § Never do](./AGENTS.md#never-do). (rationale: README § Tooling decisions)
- **Don't put slow / network / whole-project scans in `check` or pre-commit.** Advisory scans are not correctness gates; they belong under `maintain:`. (see [Feedback tiers](#feedback-tiers) and [Script prefix convention](#script-prefix-convention)) - **Don't put slow / network / whole-project scans in `check` or pre-commit.** Advisory scans are not correctness gates; they belong under `maintain:`. (see [Feedback tiers](#feedback-tiers) and [Script prefix convention](#script-prefix-convention))
- **New work starts with `npm run create:branch`, never a hand-written `git switch -c`/`git checkout -b`.** The command carries the branch precondition; branching around it skips the clean-tree, current-`main` and green-baseline checks, and the skip is invisible until a failure can no longer be attributed. (see [Branching model](#branching-model)) - **New work starts with `npm run create:branch`, never a hand-written `git switch -c`/`git checkout -b`.** The command carries the branch precondition; branching around it skips the clean-tree, current-`main` and green-baseline checks, and the skip is invisible until a failure can no longer be attributed. (see [Branching model](#branching-model))
- **Work is merged back with `npm run create:finish`, never a hand-written `git merge`.** The command carries the merge-side preconditions (clean tree, current `main`, a `feature/`/`fix/`/`chore/` branch) and runs `npm run verify` after the merge, so a merge cannot land unverified. (see [Branching model](#branching-model))
- **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow)) - **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow))
## Editor configuration
`.editorconfig` is for editor compatibility, not a gate — it is a sane fallback for the files oxfmt does not format (shell scripts, dotfiles, `LICENSE`, the commit-message template, and git's `COMMIT_EDITMSG` buffer). Where both apply, `.oxfmtrc.json` is authoritative: oxfmt is the formatter, and the overlapping `.editorconfig` keys only keep non-oxfmt editors close to the formatted result.
## Commit messages ## Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is `commit-message-template`; run `npm run setup:git-commit-message` once after cloning to register it as git's `commit.template` (or `npm run setup` to run every one-time clone step). Gitmoji subject, imperative mood, 50/72 wrapping. The template is `commit-message-template`; run `npm run setup:git-commit-message` once after cloning to register it as git's `commit.template` (or `npm run setup` to run every one-time clone step).
@@ -23,7 +28,7 @@ Examples from history: `:sparkles: Add watch tier with watch:test child`, `:recy
Script names in `package.json` use a prefix that signals _when_ the script is intended to run. A `<prefix>:<name>` script is implicitly aggregated by a `<prefix>` script (if one exists) and run by the corresponding lefthook hook or CI step. Picking the right prefix documents the script's intended lifecycle: Script names in `package.json` use a prefix that signals _when_ the script is intended to run. A `<prefix>:<name>` script is implicitly aggregated by a `<prefix>` script (if one exists) and run by the corresponding lefthook hook or CI step. Picking the right prefix documents the script's intended lifecycle:
- `create:*` — front doors of the repo's own workflow; these mutate git state rather than the source. `create:branch` opens a unit of work (asserts a clean tree, a current `main` and a green baseline before it branches), `create:release` closes one (maintainer-only). No bare `create` aggregator on purpose — see `publish:*` for the precedent. - `create:*` — front doors of the repo's own workflow; these mutate git state rather than the source. `create:branch` opens a unit of work (asserts a clean tree, a current `main` and a green baseline before it branches), `create:finish` closes the branch half (merges the current unit of work into `main` and verifies the result), `create:release` closes the release half (maintainer-only). No bare `create` aggregator on purpose — see `publish:*` for the precedent.
- `check:*` — read-only verification; never modifies files. Aggregated by `npm run check`. - `check:*` — read-only verification; never modifies files. Aggregated by `npm run check`.
- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by `npm run fix`; the diff is the review surface. - `fix:*` — mutating counterpart of a `check:*` script. Aggregated by `npm run fix`; the diff is the review surface.
- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` + unit tests); `test:unit` skips the typecheck for fast local iteration; `test:ci` adds c8 coverage. - `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` + unit tests); `test:unit` skips the typecheck for fast local iteration; `test:ci` adds c8 coverage.
@@ -41,7 +46,7 @@ Separately, some top-level scripts are **bare** (no prefix): the entry points th
The tools are organized into a feedback ladder. Each tier catches different things at different costs; the rule of thumb is "earlier tiers fire more often, faster tiers catch less, slower tiers are more thorough": The tools are organized into a feedback ladder. Each tier catches different things at different costs; the rule of thumb is "earlier tiers fire more often, faster tiers catch less, slower tiers are more thorough":
| Tier | When | What it runs | Time | | Tier | When | What it runs | Time |
| -------------------------------- | ---------------------- | --------------------------------------------------------------------- | ----- | | -------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------- | ----- |
| `npm run watch` | manual | `watch:test` — re-runs tests on file save | ~0.1s | | `npm run watch` | manual | `watch:test` — re-runs tests on file save | ~0.1s |
| Pre-commit (auto) | on stage | tsc + oxlint + oxfmt + cspell (staged files only) | ~1.3s | | Pre-commit (auto) | on stage | tsc + oxlint + oxfmt + cspell (staged files only) | ~1.3s |
| Pre-push (auto) | on push | `npm test` (full tsc + unit tests) | ~3.5s | | Pre-push (auto) | on push | `npm test` (full tsc + unit tests) | ~3.5s |
@@ -49,9 +54,9 @@ The tools are organized into a feedback ladder. Each tier catches different thin
| `npm run verify` | manual | Definition of done: `npm run check` + unit tests, one shot | ~6s | | `npm run verify` | manual | Definition of done: `npm run check` + unit tests, one shot | ~6s |
| `npm run fix` | manual | Auto-resolve fixable issues (lint, format) | ~3s | | `npm run fix` | manual | Auto-resolve fixable issues (lint, format) | ~3s |
| `npm run maintain` | manual / CI (advisory) | `maintain:knip` + `maintain:outdated` (whole-project + network scans) | ~10s | | `npm run maintain` | manual / CI (advisory) | `maintain:knip` + `maintain:outdated` (whole-project + network scans) | ~10s |
| CI build (auto) | on push to `main` | `npm run check` + `npm run test:ci` | ~30s+ | | CI build (auto) | on push to `main` / tag | `build` job (build + correctness + packaging) — see [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) | ~30s+ |
| CI maintain (auto, non-blocking) | on push to `main` | `npm run maintain` — reports, never fails the build | ~10s | | CI maintain (auto, non-blocking) | on push to `main` | `npm run maintain` — reports, never fails the build | ~10s |
| CI publish (auto) | on tag | `publish:publint` + `publish:attw`, then `npm publish` | ~10s | | CI publish (auto) | on tag | Gitea release page (body from CHANGELOG) + `publish:publint` + `publish:attw`, then `npm publish` | ~15s |
### Why these splits? ### Why these splits?
@@ -81,8 +86,8 @@ This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert
- **Base branch:** `main` - **Base branch:** `main`
- **Branch naming:** `feature/<desc>` / `fix/<desc>` / `chore/<desc>` - **Branch naming:** `feature/<desc>` / `fix/<desc>` / `chore/<desc>`
- **Starting work:** `npm run create:branch -- <prefix>/<desc>`. It refuses, without changing anything, unless the working tree is clean (untracked files included), no merge/rebase/cherry-pick is in progress, `main` matches its upstream, and `npm run test` is green on `main` — so a later failure is always attributable to your edits. The prefix is still _your_ call, inferred from the task; the script validates it rather than guessing it. - **Starting work:** `npm run create:branch -- <prefix>/<desc>`. It refuses, without changing anything, unless the working tree is clean (untracked files included), no merge/rebase/cherry-pick is in progress, `main` matches its upstream, and `npm run test` is green on `main` — so a later failure is always attributable to your edits. The prefix is still _your_ call, inferred from the task; the script validates it rather than guessing it.
- **Merging:** `git checkout main && git merge --no-ff <branch>` (local PR — review the diff yourself before closing the branch). - **Merging:** `npm run create:finish` (on the branch). It asserts the same clean-tree / no-operation / current-`main` preconditions, fast-forwards a stale `main` (a true divergence is refused), merges the branch `--no-ff`, runs `npm run verify`, and deletes the branch only after the merge is green. The push is deliberately left to `create:release`, so the merge stays local and reviewable — read the diff yourself before finishing.
- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate. - CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate. The one exception: a push headed by a release commit (`:rocket: Release x.y.z`) skips the full `build`/`maintain` jobs, because `create:release` pushes the tag for that exact commit right after and the tag run is the authoritative one (see `release-gate` in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml)).
- **Releases are NOT triggered by pushes.** Only the maintainer triggers a release (see [Publishing workflow](#publishing-workflow)). - **Releases are NOT triggered by pushes.** Only the maintainer triggers a release (see [Publishing workflow](#publishing-workflow)).
## Publishing workflow ## Publishing workflow
@@ -90,6 +95,6 @@ This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert
Publishing is CI-only by policy. Local `npm publish` is not supported. The maintainer triggers releases from `main`: Publishing is CI-only by policy. Local `npm publish` is not supported. The maintainer triggers releases from `main`:
1. All intended changes are merged to `main` and passing CI. 1. All intended changes are merged to `main` and passing CI.
2. The maintainer runs `npm run create:release` — an interactive prompt suggests a version (based on the latest CHANGELOG entry); the maintainer confirms or edits it. 2. The maintainer runs `npm run create:release`. VS Code opens `CHANGELOG.md` to finalize the `[Unreleased]` notes; because pubv refuses a dirty tree, any edit is committed first (then folded into the release commit), and pubv's interactive prompt suggests a version from those notes — the maintainer confirms or edits it.
3. `scripts/release.sh` creates a single release commit (changelog + package.json bump, amended into one commit), tags it, and pushes everything to Gitea. 3. `scripts/release.sh` creates a single release commit (graduated changelog + package.json bump, amended into one commit), tags it, and pushes everything to Gitea.
4. CI runs on the push: `npm run check` + `npm run test:ci` on the commit; then the `publish` job fires on the tag: `build` → `publish:publint` → `publish:attw` → `npm publish --access public`. The publish-tier checks must pass before the artifact is published. 4. CI fires on both pushes: the `publish` job runs on the tag (`build` + publish-tier checks + release page + `npm publish`), while the branch run's `release-gate` job recognizes the release commit and skips `build`/`maintain` — the tag verifies the identical SHA, so no work is duplicated. The job graph lives in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) — keep that file, not this list, as the source of truth. The publish-tier checks must pass before the artifact is published. The `publish` job also creates the Gitea release page from the matching Keep-a-Changelog section (`scripts/release-notes.sh`); it runs _before_ `npm publish` so a broken page fails CI without consuming a version, and `npm publish` stays the last step.
+8 -6
View File
@@ -20,7 +20,7 @@ What each tier runs, when it fires and what it costs:
### Tooling ### Tooling
- **TypeScript 7** — type checker and build (`tsc`). - **TypeScript 7** — type checker and build (`tsc`).
- **node --test** + `--experimental-strip-types` — test runner (Node 22.6+, flag dropped on Node 24). - **node --test** + `--strip-types` — test runner.
- **c8** — code coverage for `test:ci`. - **c8** — code coverage for `test:ci`.
- **oxlint** — Rust-based linter, with type-aware rules powered by **oxlint-tsgolint** (typescript-go). - **oxlint** — Rust-based linter, with type-aware rules powered by **oxlint-tsgolint** (typescript-go).
- **oxfmt** — Rust-based formatter (Prettier-compatible). Formats JS/TS, JSON/JSONC, YAML, Markdown, MDX, and more; built-in `package.json` key sorting replaces `sort-package-json`. - **oxfmt** — Rust-based formatter (Prettier-compatible). Formats JS/TS, JSON/JSONC, YAML, Markdown, MDX, and more; built-in `package.json` key sorting replaces `sort-package-json`.
@@ -38,8 +38,9 @@ Each tool's configuration trade-off is recorded in [Tooling decisions](#tooling-
The choice and configuration of each tool above is the result of deliberate trade-offs, not defaults. The non-obvious ones: The choice and configuration of each tool above is the result of deliberate trade-offs, not defaults. The non-obvious ones:
- **`tsconfig.json` extends `@tsconfig/strictest` + `@tsconfig/node26`**; `tsconfig.build.json` extends it to add the emit-only options (`declaration`, `sourceMap`, `outDir`, `target: es2024`, `rewriteRelativeImportExtensions: true`) and to exclude test files. This separation lets the editor and CI type-check from one config while the build emits from the other. - **`tsconfig.json` extends `@tsconfig/strictest` + `@tsconfig/node26`**; `tsconfig.build.json` extends it to add the emit-only options (`declaration`, `sourceMap`, `inlineSources`, `outDir`, `target: es2024`, `rewriteRelativeImportExtensions: true`) and to exclude test files. `inlineSources` embeds the original TypeScript in `dist/*.js.map`, so debuggers can map into `src/` without it being shipped; `declarationMap` is intentionally off because a `.d.ts.map` cannot embed source and would dangle. This separation lets the editor and CI type-check from one config while the build emits from the other.
- **Source imports use `.ts` extensions** so `node --strip-types` resolves them at test time. `rewriteRelativeImportExtensions: true` in `tsconfig.build.json` rewrites them to `.js` in the emitted `dist/*` output, so consumers see conventional ESM imports. - **`npm run build` first runs a `prebuild` hook that empties `dist/`.** `tsc` does not prune orphaned emit output — dropping `declarationMap`, for example, left stale `*.d.ts.map` files behind — so the build must start from an empty `dist/` to be reproducible. `prebuild` removes only `dist`; the manual `clean` still resets `dist` + `coverage`, so a local coverage report survives a build.
- **Source imports use `.ts` extensions** so `node --strip-types` resolves them at test time. `rewriteRelativeImportExtensions: true` in `tsconfig.build.json` rewrites them to `.js` in the emitted JavaScript; the emitted `.d.ts` keep the `.ts` specifier, which TypeScript >= 5.0 resolves (see [Requirements](#requirements)), so no post-processing step is needed.
- **Type-aware oxlint is enabled declaratively** via `options.typeAware: true` in `.oxlintrc.json` (powered by `oxlint-tsgolint`). The script commands stay clean — no CLI flag — and type-aware mode is a property of the config, not the invocation. - **Type-aware oxlint is enabled declaratively** via `options.typeAware: true` in `.oxlintrc.json` (powered by `oxlint-tsgolint`). The script commands stay clean — no CLI flag — and type-aware mode is a property of the config, not the invocation.
- **Source-level `oxlint-disable` directives** are used for known type-aware false positives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`). The disable lives next to the code it silences, not in `.oxlintrc.json`, so the trade-off is visible to anyone reading the source. - **Source-level `oxlint-disable` directives** are used for known type-aware false positives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`). The disable lives next to the code it silences, not in `.oxlintrc.json`, so the trade-off is visible to anyone reading the source.
- **`knip --include dependencies,exports,files`** intentionally omits the `types` category, which produces systematic false positives for libraries whose exported types are part of the public API. The targeted scope keeps the signal high without config-file boilerplate. - **`knip --include dependencies,exports,files`** intentionally omits the `types` category, which produces systematic false positives for libraries whose exported types are part of the public API. The targeted scope keeps the signal high without config-file boilerplate.
@@ -47,17 +48,18 @@ The choice and configuration of each tool above is the result of deliberate trad
- **`check:tsc` runs first** in the `npm run check` chain so a type error short-circuits the rest (faster feedback than letting oxlint/oxfmt run and then failing on tsc at the end). - **`check:tsc` runs first** in the `npm run check` chain so a type error short-circuits the rest (faster feedback than letting oxlint/oxfmt run and then failing on tsc at the end).
- **The pre-commit hook sets the `LEFTHOOK_FILES` env var** to the staged-files list, and the affected scripts use `${LEFTHOOK_FILES:-<default>}` to default to the whole project when invoked manually. This keeps `package.json#scripts` as the single source of truth for the underlying commands — `lefthook.yml` only describes _what to run on which files_. - **The pre-commit hook sets the `LEFTHOOK_FILES` env var** to the staged-files list, and the affected scripts use `${LEFTHOOK_FILES:-<default>}` to default to the whole project when invoked manually. This keeps `package.json#scripts` as the single source of truth for the underlying commands — `lefthook.yml` only describes _what to run on which files_.
- **`tslib` and `type-fest` are deliberately not used.** `tslib` is a runtime helper for old ES3/ES5 targets (the project targets ES2024); `type-fest` was never imported. knip caught both. - **`tslib` and `type-fest` are deliberately not used.** `tslib` is a runtime helper for old ES3/ES5 targets (the project targets ES2024); `type-fest` was never imported. knip caught both.
- **`@spences10/pi-lsp` is pinned to `0.0.46` and is read-only by design.** The package inspects `node_modules/typescript`, sees major ≥ 7 with no `lib/tsserver.js` (true of the `typescript-go` / `tsgo` port), and spawns the repo's own `tsc --lsp --stdio` binary — no `typescript-language-server` dependency is required. Earlier releases (`≤ 0.0.10`) hard-wire to `typescript-language-server --stdio` and are TS6-only. The tool is _intermediate_ agent feedback (hover, references, definition, symbols, diagnostics); it has no rename / code-action / apply-edit surface, and never a correctness gate — `npm run check` / `verify` remain that. - **`@spences10/pi-lsp` is pinned to `0.0.46` and is read-only by design.** The package inspects `node_modules/typescript`, sees major ≥ 7 with no `lib/tsserver.js` (true of the `typescript-go` / `tsgo` port), and spawns the repo's own `tsc --lsp --stdio` binary — no `typescript-language-server` dependency is required. Earlier releases (`≤ 0.0.10`) hard-wire to `typescript-language-server --stdio` and are TS6-only. The tool is _intermediate_ agent feedback (hover, references, definition, symbols, diagnostics); it has no rename / code-action / apply-edit surface, and never a correctness gate — `npm run check` / `verify` remain that. `.pi/settings.json` is the shared, committed declaration; `.pi/npm/` is a gitignored install cache that pi recreates automatically on a trusted startup (it runs `npm install` for any missing project package), so the cache is deliberately not tracked.
### Requirements ### Requirements
- Node.js >= 26 (engines field; pinned via `.node-version`). - Node.js >= 26 (engines field; pinned via `.node-version`).
- TypeScript >= 5.0 to consume the published declarations. The emitted `.d.ts` use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers; both resolve on TS >= 5.0 in `node10`/`node16`/`nodenext`/`bundler`.
## VSCode integration ## VSCode integration
- Recommended extensions: see `.vscode/extensions.json` (oxc, cspell). - Recommended extensions: see `.vscode/extensions.json` (oxc, cspell, TypeScript native-preview, EditorConfig, todo-tasks).
- TypeScript 7 is used via the `typescriptteam.native-preview` extension. - TypeScript 7 is used via the `typescriptteam.native-preview` extension.
- oxc extension provides oxlint squiggles and oxfmt format-on-save. - oxc extension provides oxlint squiggles and oxfmt format-on-save; `.vscode/settings.json` pins it per language so a user's local `[language]` formatter settings cannot override the project's choice.
## Contributing ## Contributing
+16 -23
View File
@@ -5,22 +5,9 @@ Backlog and tracking for tiny-pattern-ts. Managed in vscode-todotasks format.
--- ---
Setup: Setup:
☐ Establish release CI workflow: post-tag checks + npm publish after release tag push => pi --session 01a06e72-e61e-7327-b3e9-3e11749a523f @high ✔ Add gitea release page in CI @high @done
☐ Add gitea release page in CI @high ☐ Manually verify the Gitea release page on a real tag push (needs main) @high
☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @high ☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @high
✔ Document branching model @done
✔ Describe branching model: when to branch, base branch, naming @done
✔ Clarify release workflow: who pushes `main`, CI as post-merge gate, drop PR usage @done (9/8/2026, 2:54:32 PM)
✔ Evaluate connecting the agent to lsp typescript server @done
- might be more difficult with TS7, since LSP Server has changed from 6
- but since vscode is running TS7, maybe it is possible to connect to the TS7 LSP server of vscode
- LSP servers in general?
- maybe skills?
→ resolved: adopted @spences10/pi-lsp@0.0.46 as a project-local pi extension (`.pi/settings.json`). It auto-detects the repo's TypeScript 7 (no `lib/tsserver.js`) and spawns `tsc --lsp --stdio` — the same tsgo binary VS Code's native-preview uses. Read-only: hover / definition / references / symbols / diagnostics. No skill; skills don't own persistent processes.
✔ Adopt a `setup:` prefix for one-time clone configuration @done
✔ Register `use:git-commit-message` as its first member (and retire `use:` — it is in no prefix list) @done
✔ Create an umbrella 'setup' task that runs all setup tasks - currently one @done
v1.0: v1.0:
☐ API surface is stable and fully typed ☐ API surface is stable and fully typed
@@ -31,10 +18,6 @@ v1.0:
☐ Achieve 100% branch coverage on `src/pattern.ts` ☐ Achieve 100% branch coverage on `src/pattern.ts`
☐ Achieve 100% branch coverage on `src/match.ts` ☐ Achieve 100% branch coverage on `src/match.ts`
☐ Achieve 100% branch coverage on `src/index.ts` ☐ Achieve 100% branch coverage on `src/index.ts`
☐ `dist/` output is clean
☐ Verify `.d.ts` declarations match public exports
☐ Ensure `.js` files use `.js` extensions (not `.ts`)
☐ Test `attw --profile esm-only` passes
Bugs: Bugs:
@@ -48,7 +31,17 @@ Documentation:
☐ Create backlog tasks for implementation ☐ Create backlog tasks for implementation
Maintenance: Maintenance:
✔ Set up lefthook pre-commit hook @done (9/8/2026, 10:52:56 AM) ☐ Serve CI coverage over a tiny self-hosted webserver (replace the zip artifact) @low
✔ Configure tsconfig strictest + node26 profiles @done (9/8/2026, 10:08:37 AM) ✔ Add a minimal dir-listing webserver to the gitea docker setup (e.g. caddy `file_server browse` reusing the existing reverse proxy, or any single-binary static server, lipanski/docker-static-website) @done (9/13/2026, 9:02:37 PM)
✔ Add check:tsc as first tier in `npm run check` @done (9/8/2026, 10:08:37 AM) ✔ drop the `actions/upload-artifact` coverage step in favour of the shared-dir layout @done (9/13/2026, 10:37:22 PM)
✔ Pin Node.js >= 26 via `.node-version` @done (9/8/2026, 10:08:38 AM) ☐ Explore serving coverage for non-tag pushes (e.g. `main/coverage`, PR previews) @low
✔ Manually verify the coverage was created on a real tag push (needs main) @low @done (9/14/2026, 1:55:03 PM)
→ design: no deploy step in CI; the webserver just exposes the shared directory (decided over Gitea Pages / Codecov — neither confirmed available/ wanted)
☐ serve docs over self hosted server @low
☐ Add a minimal dir-listing webserver to the gitea docker setup for serving docs (reuse existing reverse proxy)
☐ CI writes docs to a shared volume keyed by project + tag (e.g. `/docs/tiny-pattern-ts/<tag>/`)
☐ Browse to `…/docs/<repo>/<tag>/index.html` in the browser
☐ serve landing page over self hosted server @low
☐ Add a minimal dir-listing webserver to the gitea docker setup for serving landing page (reuse existing reverse proxy)
☐ CI writes landing page to a shared volume keyed by project + tag (e.g. `/landing/tiny-pattern-ts/<tag>/`)
☐ Browse to `…/tiny-pattern-ts/index.html` in the browser
+2
View File
@@ -22,11 +22,13 @@
"tsgo", "tsgo",
"tsserver", "tsserver",
"gitea", "gitea",
"lipanski",
"pubv", "pubv",
"knope", "knope",
"runwisp", "runwisp",
"glab", "glab",
"postversion", "postversion",
"prebuild",
"Zilla", "Zilla",
"kacl", "kacl",
"bestikk", "bestikk",
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/knip/schema.json",
"entry": ["scripts/*.ts"],
"ignoreDependencies": ["@runwisp/pubv"]
}
+322 -322
View File
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.0.0", "version": "0.1.1",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)", "description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
"keywords": [ "keywords": [
"adt", "adt",
@@ -27,8 +27,6 @@
], ],
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -40,16 +38,18 @@
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
"prebuild": "rm -rf dist",
"check": "npm run check:tsc && npm run check:oxlint && npm run check:oxfmt && npm run check:cspell", "check": "npm run check:tsc && npm run check:oxlint && npm run check:oxfmt && npm run check:cspell",
"check:cspell": "cspell lint ${LEFTHOOK_FILES:-.}", "check:cspell": "cspell lint ${LEFTHOOK_FILES:-.}",
"check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}", "check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}",
"check:oxlint": "oxlint ${LEFTHOOK_FILES:-src}", "check:oxlint": "oxlint ${LEFTHOOK_FILES:-src scripts}",
"check:tsc": "tsc", "check:tsc": "tsc",
"clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "clean": "rm -rf dist coverage",
"fix": "npm run fix:oxlint && npm run fix:oxfmt", "fix": "npm run fix:oxlint && npm run fix:oxfmt",
"fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}", "fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}",
"fix:oxlint": "oxlint --fix src", "fix:oxlint": "oxlint --fix src scripts",
"create:branch": "./scripts/branch.sh", "create:branch": "./scripts/branch.sh",
"create:finish": "./scripts/finish.sh",
"create:release": "./scripts/release.sh", "create:release": "./scripts/release.sh",
"maintain": "npm run maintain:knip; npm run maintain:outdated", "maintain": "npm run maintain:knip; npm run maintain:outdated",
"maintain:knip": "knip --include dependencies,exports,files", "maintain:knip": "knip --include dependencies,exports,files",
@@ -87,6 +87,6 @@
"node": ">=26" "node": ">=26"
}, },
"allowScripts": { "allowScripts": {
"lefthook@2.1.12": true "lefthook@2.1.14": true
} }
} }
+10 -6
View File
@@ -31,11 +31,12 @@ set -eu
# describes nothing anyone wants, and `publish:*` already sets the precedent for # describes nothing anyone wants, and `publish:*` already sets the precedent for
# a prefix without one. # a prefix without one.
# #
# Also rejected: a full git-flow CLI wrapping the merge too (merging ends in # Also rejected here: reusing `pubv`'s preflight (release-shaped, third-party,
# "review the diff yourself", which is judgment, and only the start half carries # and it would make branch start pay a build + pack it has no use for). The
# a verification burden); and reusing `pubv`'s preflight (release-shaped, # merge half was originally rejected too ("review the diff yourself" is
# third-party, and it would make branch start pay a build + pack it has no use # judgment), but it now has its own front door — `create:finish` — which owns
# for). # the merge-side preconditions and the post-merge `verify`, so the start half
# does not have to carry that burden.
# #
# Every refusal is non-mutating except the baseline test, which runs on `main` # Every refusal is non-mutating except the baseline test, which runs on `main`
# after we switch there — so a red `main` restores the branch you started on # after we switch there — so a red `main` restores the branch you started on
@@ -105,7 +106,10 @@ git show-ref --verify --quiet "refs/heads/${BASE}" || {
# Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and # Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and
# `origin_https`, and `main` tracks the latter — `git fetch origin main` would # `origin_https`, and `main` tracks the latter — `git fetch origin main` would
# check currency against a ref that is never updated here. # check currency against a ref that is never updated here.
UPSTREAM=$(git rev-parse --quiet --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null || true) # `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on
# failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet
# form prints nothing on failure and the fallback runs.
UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null) || UPSTREAM=""
if [ -n "${UPSTREAM}" ]; then if [ -n "${UPSTREAM}" ]; then
git fetch --quiet "${UPSTREAM%/*}" "${UPSTREAM#*/}" || { git fetch --quiet "${UPSTREAM%/*}" "${UPSTREAM#*/}" || {
echo "error: '${UPSTREAM}' check failed: could not reach '${UPSTREAM%/*}'." >&2 echo "error: '${UPSTREAM}' check failed: could not reach '${UPSTREAM%/*}'." >&2
+136
View File
@@ -0,0 +1,136 @@
#!/bin/sh
set -eu
# Feature-finish front door. Run as `npm run create:finish`.
#
# Why this exists: `create:branch` opens a unit of work, but the close half
# (`git checkout main && git merge --no-ff <branch>`) stayed prose in the
# branching model, so it drifted per contributor and per session. This is the
# mirror image of `create:branch`: it asserts the same preconditions (clean
# tree, no in-progress operation, `main` matching its upstream), merges the
# current `feature/`/`fix/`/`chore/` branch into `main`, proves the result with
# `npm run verify`, and only then deletes the branch.
#
# `create:branch`'s comment argued against wrapping the merge as "judgment —
# review the diff yourself". That judgment still lives here, just moved: the
# maintainer reviews the handover *before* invoking this, and the script only
# commits the merge, never the push. The push is owned by `create:release`, so
# the release commit and its tag leave together and a local merge stays
# reviewable (and can be reverted with `git revert -m 1`) until then. `--no-ff`
# keeps the unit of work visible in `git log`.
#
# Unlike `create:branch` a stale `main` is fast-forwarded instead of refused:
# the tree is clean (checked above) and `main` is not the checked-out branch
# yet, so there is no local state to lose. True divergence (local commits *and*
# upstream commits) is still refused — that needs a human.
#
# On a merge conflict we abort and return to the feature branch, so a failed
# finish never strands you on a half-merged `main`.
BASE="main"
PREFIXES="feature fix chore"
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo "error: not inside a git work tree." >&2
exit 1
}
STATE_ROOT=$(git rev-parse --absolute-git-dir)
for state in MERGE_HEAD rebase-merge rebase-apply CHERRY_PICK_HEAD BISECT_LOG; do
[ -e "${STATE_ROOT}/${state}" ] && {
echo "error: a '${state}' operation is in progress; finish or abort it first." >&2
exit 1
}
done
# `--porcelain` is deliberately stricter than `git diff --quiet`: it also
# reports untracked files, which would otherwise not be part of the merge and
# silently outlive the branch deletion.
DIRTY=$(git status --porcelain)
if [ -n "${DIRTY}" ]; then
echo "error: working tree is not clean:" >&2
echo "${DIRTY}" | sed 's/^/ /' >&2
exit 1
fi
START_REF=$(git symbolic-ref --quiet --short HEAD || true)
if [ -z "${START_REF}" ]; then
echo "error: detached HEAD; switch to the branch you want to finish." >&2
exit 1
fi
if [ "${START_REF}" = "${BASE}" ]; then
echo "error: already on '${BASE}'; switch to the branch to finish." >&2
exit 1
fi
MATCH=0
for p in ${PREFIXES}; do
case "${START_REF}" in
"${p}/"*) MATCH=1 ;;
esac
done
if [ "${MATCH}" -ne 1 ]; then
echo "error: '${START_REF}' must start with one of: ${PREFIXES}." >&2
echo " refusing to merge a branch that is not a unit of work." >&2
exit 1
fi
git show-ref --verify --quiet "refs/heads/${BASE}" || {
echo "error: no local '${BASE}' to merge into." >&2
exit 1
}
# Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and
# `origin_https`, and `main` tracks the latter — `git fetch origin main` would
# check currency against a ref that is never updated here.
# `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on
# failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet
# form prints nothing on failure and the fallback runs.
UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null) || UPSTREAM=""
BEHIND=0
if [ -n "${UPSTREAM}" ]; then
git fetch --quiet "${UPSTREAM%/*}" "${UPSTREAM#*/}" || {
echo "error: '${UPSTREAM}' check failed: could not reach '${UPSTREAM%/*}'." >&2
echo " refusing to merge onto a possibly stale '${BASE}'." >&2
exit 1
}
BEHIND=$(git rev-list --count "${BASE}..${UPSTREAM}")
AHEAD=$(git rev-list --count "${UPSTREAM}..${BASE}")
if [ "${AHEAD}" -ne 0 ] && [ "${BEHIND}" -ne 0 ]; then
echo "error: '${BASE}' has diverged from '${UPSTREAM}' (ahead ${AHEAD}, behind ${BEHIND})." >&2
echo " reconcile '${BASE}' with '${UPSTREAM}' before finishing." >&2
exit 1
fi
else
echo "warning: '${BASE}' has no upstream; freshness against the remote is unchecked." >&2
fi
echo "Finishing into ${BASE}:"
git --no-pager log --oneline --no-decorate "${BASE}..${START_REF}" | sed 's/^/ /'
git switch --quiet "${BASE}"
if [ "${BEHIND}" -ne 0 ]; then
echo "Fast-forwarding ${BASE} to ${UPSTREAM} (${BEHIND} commit(s))."
git merge --quiet --ff-only "${UPSTREAM}"
fi
if ! git merge --quiet --no-ff -m ":twisted_rightwards_arrows: Merge ${START_REF} into ${BASE}" "${START_REF}"; then
echo "error: merge of '${START_REF}' failed; aborting and returning to it." >&2
git merge --abort 2>/dev/null || true
git switch --quiet "${START_REF}"
exit 1
fi
echo "Verify: npm run verify"
if ! npm run --silent verify; then
echo "error: 'npm run verify' is red after the merge." >&2
echo " the merge is local and not yet pushed; fix it on '${BASE}' and commit," >&2
echo " then drop the now-merged branch with 'git branch -d ${START_REF}'." >&2
exit 1
fi
git branch --delete "${START_REF}" >/dev/null
echo "Merged ${START_REF} into ${BASE} and deleted the branch."
echo "Next: npm run create:release (or git push, for a merge with no release)."
+144
View File
@@ -0,0 +1,144 @@
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
/**
* Emit precompressed `.br` / `.gz` / `.zst` sidecars next to every text asset
* under the given directories, keeping the originals. The Gitea pages service
* (`static-web-server` with `SERVER_COMPRESSION_STATIC=true`) serves the sidecar
* matching `Accept-Encoding` and falls back to the original for the rest.
*
* Usage: node --strip-types scripts/precompress.ts <dir> [<dir>...]
*/
/**
* Only extensions worth compressing. Images, fonts and archives are already
* compressed, so a sidecar would only make them bigger.
*/
const TEXT_EXTENSIONS: ReadonlySet<string> = new Set([
".css",
".htm",
".html",
".info",
".js",
".json",
".map",
".md",
".mjs",
".svg",
".txt",
".xml",
".yaml",
".yml",
]);
const GZIP_LEVEL = 9;
const ZSTD_LEVEL = 19;
const INITIAL_COUNT = 0;
/** `process.argv` is `[node, script, ...args]`; drop the first two entries. */
const ARGV_PREFIX_LENGTH = 2;
const FAILURE_EXIT_CODE = 1;
interface Encoder {
readonly suffix: string;
readonly encode: (input: Buffer) => Buffer;
}
const ENCODERS: readonly Encoder[] = [
{
suffix: ".br",
encode: (input) =>
zlib.brotliCompressSync(input, {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]:
zlib.constants.BROTLI_MAX_QUALITY,
},
}),
},
{
suffix: ".gz",
encode: (input) => zlib.gzipSync(input, { level: GZIP_LEVEL }),
},
{
suffix: ".zst",
encode: (input) =>
zlib.zstdCompressSync(input, {
params: {
[zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL,
},
}),
},
];
interface Totals {
assets: number;
sidecars: number;
savedBytes: number;
}
/** Depth-first list of every regular file under `directory`, recursively. */
const listFiles = (directory: string): string[] => {
const files: string[] = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...listFiles(entryPath));
} else if (entry.isFile()) {
files.push(entryPath);
}
}
return files;
};
const writeSidecar = (
target: string,
input: Buffer,
encoder: Encoder,
): number => {
const compressed = encoder.encode(input);
if (compressed.byteLength < input.byteLength) {
fs.writeFileSync(target, compressed);
return input.byteLength - compressed.byteLength;
}
// Drop a stale sidecar: it would still win at the server.
fs.rmSync(target, { force: true });
return INITIAL_COUNT;
};
const precompress = (file: string, totals: Totals): void => {
if (!TEXT_EXTENSIONS.has(path.extname(file).toLowerCase())) {
return;
}
totals.assets += 1;
const input = fs.readFileSync(file);
for (const encoder of ENCODERS) {
const saved = writeSidecar(`${file}${encoder.suffix}`, input, encoder);
if (saved > INITIAL_COUNT) {
totals.sidecars += 1;
totals.savedBytes += saved;
}
}
};
const main = (directories: readonly string[]): void => {
if (directories.length === INITIAL_COUNT) {
process.stderr.write("usage: precompress.ts <dir> [<dir>...]\n");
process.exitCode = FAILURE_EXIT_CODE;
return;
}
const totals: Totals = {
assets: INITIAL_COUNT,
sidecars: INITIAL_COUNT,
savedBytes: INITIAL_COUNT,
};
for (const directory of directories) {
for (const file of listFiles(directory)) {
precompress(file, totals);
}
}
process.stdout.write(
`precompressed ${totals.assets} text assets into ${totals.sidecars} sidecars (saved ${totals.savedBytes} bytes)\n`,
);
};
main(process.argv.slice(ARGV_PREFIX_LENGTH));
+64
View File
@@ -0,0 +1,64 @@
#!/bin/sh
set -eu
# Print the Keep-a-Changelog section for a release tag, so CI can use it as the
# body of the Gitea release page without re-implementing CHANGELOG parsing.
#
# Run as `scripts/release-notes.sh <tag>`. A leading `v` is tolerated so both
# `v1.2.3` and `1.2.3` match the `## [1.2.3]` heading. Prints to stdout and
# exits non-zero when the tag has no section, so a release can never publish
# with an empty body.
TAG="${1:-}"
CHANGELOG="${CHANGELOG:-CHANGELOG.md}"
if [ -z "${TAG}" ]; then
echo "Error: usage: $0 <tag>" >&2
exit 1
fi
if [ ! -f "${CHANGELOG}" ]; then
echo "Error: ${CHANGELOG} not found." >&2
exit 1
fi
# `v1.2.3` and `1.2.3` are the same release; the heading only ever uses the
# bare version.
VERSION="${TAG#v}"
awk -v version="${VERSION}" '
# A new section ends the one we are printing (or is the one we want).
/^## \[/ {
if (found) exit
heading = $0
sub(/^## \[/, "", heading)
sub(/\].*$/, "", heading)
if (heading == version) found = 1
next
}
# Link-reference definitions live at the bottom of the file and are never
# part of the section body. Stopping here keeps the last release notes
# from picking them up (there is no following heading to stop at).
/^\[[^]]+\]:/ { exit }
found {
if ($0 ~ /^[[:space:]]*$/) {
# Buffer blank lines so trailing ones at the end of the section
# are dropped instead of leaking into the release body.
if (started) pending = pending $0 "\n"
} else {
printf "%s%s\n", pending, $0
pending = ""
started = 1
}
}
END {
if (!found) {
printf "Error: no CHANGELOG section for [%s].\n", version > "/dev/stderr"
exit 1
}
}
' "${CHANGELOG}"
+56 -4
View File
@@ -13,6 +13,12 @@ set -eu
# package.json + the lockfile; `--amend` folds them into pubv's single commit; # package.json + the lockfile; `--amend` folds them into pubv's single commit;
# tag AFTER the amend (so the tag is never orphaned) and push. # tag AFTER the amend (so the tag is never orphaned) and push.
# #
# The notes are finalized in VS Code *before* pubv: the [Unreleased] body is
# what pubv's bump heuristic reads, so editing afterwards would inform the
# changelog only, not the version choice. pubv refuses a dirty tree, so that
# edit is committed as a staging commit and folded back into the single release
# commit below.
#
# Rejected: the conventional-commits family (our history is gitmoji, not # Rejected: the conventional-commits family (our history is gitmoji, not
# Conventional; and we want hand-written notes); changesets/rtk (config + a # Conventional; and we want hand-written notes); changesets/rtk (config + a
# heavier version/publish flow that fights our CI-only publish); knope/kacl/ # heavier version/publish flow that fights our CI-only publish); knope/kacl/
@@ -23,18 +29,52 @@ set -eu
# exactly what this ~30-line version replaces. # exactly what this ~30-line version replaces.
CHANGELOG="CHANGELOG.md" CHANGELOG="CHANGELOG.md"
BASE="main"
if ! command -v code >/dev/null 2>&1; then if ! command -v code >/dev/null 2>&1; then
echo "Error: 'code' (VS Code CLI) not found; install it or remove the editor step." >&2 echo "Error: 'code' (VS Code CLI) not found; install it or remove the editor step." >&2
exit 1 exit 1
fi fi
# Releases are cut from `main` (see CONTRIBUTING § Publishing workflow). Make
# that explicit rather than relying on pubv's default-branch check, so the
# error names `main` even when the remote's default is configured differently.
CURRENT=$(git symbolic-ref --quiet --short HEAD || true)
if [ "${CURRENT}" != "${BASE}" ]; then
echo "Error: releases are cut from '${BASE}', but HEAD is '${CURRENT:-detached}'." >&2
exit 1
fi
# pubv decides the "default branch" by reading the *local*
# `refs/remotes/origin/HEAD`, not by asking the remote, and `git fetch` never
# updates that ref. After a default-branch change — or a clone from when the
# default was different — it goes stale and pubv warns/fails because the
# current branch (main) does not match it, even though main *is* the remote
# default. Refresh it from the remote first, so pubv's branch preflight
# compares against reality. (Without a network this fails, but so would the
# push pubv is about to do, so it is a real error rather than one to swallow.)
if ! git remote set-head origin --auto >/dev/null 2>&1; then
echo "Error: could not refresh origin/HEAD; check connectivity to origin." >&2
exit 1
fi
# The [Unreleased] body drives pubv's bump heuristic, so finalize it first.
echo "Opening ${CHANGELOG} in VS Code to finalize the release notes..."
code --wait "${CHANGELOG}"
# pubv refuses a dirty tree (its "continue with a dirty tree?" prompt defaults
# to No), so a changed changelog must be committed before it runs. That commit
# is staging only — the fold below rewrites it into the single release commit.
NOTES_MSG=":memo: Finalize release notes"
if [ -n "$(git status --porcelain -- "${CHANGELOG}")" ]; then
echo "Committing finalized release notes..."
git add "${CHANGELOG}"
git commit -m "${NOTES_MSG}"
fi
echo "Running pubv..." echo "Running pubv..."
pubv --no-tag --no-push --tag-prefix=none pubv --no-tag --no-push --tag-prefix=none
echo "Opening ${CHANGELOG} in VS Code..."
code --wait "${CHANGELOG}"
echo "Reading version from ${CHANGELOG}..." echo "Reading version from ${CHANGELOG}..."
VERSION=$( VERSION=$(
@@ -52,9 +92,21 @@ echo "Release version: ${VERSION}"
echo "Updating package.json and package-lock.json..." echo "Updating package.json and package-lock.json..."
npm version "${VERSION}" --no-git-tag-version npm version "${VERSION}" --no-git-tag-version
# If pubv's graduation commit sits on top of our staging notes commit, drop it
# back into the index so the amend below rewrites the notes commit into the one
# release commit. A message check, not a flag, so a re-run after pubv aborted
# still folds a notes commit left behind by the earlier attempt.
if [ "$(git log -1 --format=%s HEAD~1 2>/dev/null || true)" = "${NOTES_MSG}" ]; then
git reset --soft HEAD~1
fi
echo "Amending release commit..." echo "Amending release commit..."
git add package.json package-lock.json "${CHANGELOG}" git add package.json package-lock.json "${CHANGELOG}"
git commit --amend -m ":bookmark: Release ${VERSION}" # The exact message format is load-bearing: the `release-gate` job in
# .gitea/workflows/ci.yml recognizes `:rocket: Release x.y.z` on main and
# skips the full CI run, since the tag push immediately after verifies the
# identical SHA (and publishes). Keep the two in sync.
git commit --amend -m ":rocket: Release ${VERSION}"
echo "Creating tag ${VERSION}..." echo "Creating tag ${VERSION}..."
git tag "${VERSION}" git tag "${VERSION}"
+1 -1
View File
@@ -4,8 +4,8 @@
"noEmit": false, "noEmit": false,
"target": "es2024", "target": "es2024",
"declaration": true, "declaration": true,
"declarationMap": true,
"sourceMap": true, "sourceMap": true,
"inlineSources": true,
"outDir": "dist", "outDir": "dist",
"rewriteRelativeImportExtensions": true, "rewriteRelativeImportExtensions": true,
"rootDir": "src" "rootDir": "src"
+1 -1
View File
@@ -8,5 +8,5 @@
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true "verbatimModuleSyntax": true
}, },
"include": ["src"] "include": ["src", "scripts"]
} }