Skip to content

Development ​

한국어.

Toolchain ​

ToolVersionSource
Node.js26.12.0node of mise.toml, the pin of kit; make install-tools TOOLS=npm installs it with mise at the digest of mise.lock into var/tools and writes var/tools/bin/node, which every recipe runs as $(NODE) (kit K171); engines.node of every package.json is >=26.12.0
npm12.2.0packageManager of package.json; make install-tools TOOLS=npm installs it into var/tools
mise2026.10.8config/toolchain.json mise, with the sha256 of its archive by platform; make install-tools TOOLS=npm installs it into var/tools after npm
Go1.27.2go of mise.toml, the pin of kit, which the go directive of every go.mod equals with no toolchain line; make install-tools installs it with mise at the digest of mise.lock
Rust1.99.0 with rustfmt and clippyrust-toolchain.toml, the pin of kit, which rust-version of every Cargo package equals; installed with rustup, ~/.cargo/bin is added to PATH by the Makefile
PHP8.5config/toolchain.json, the minor version and the pin of kit; require.php of every composer.json is >=8.5 and config.platform.php is 8.5.0; the patch of each run is recorded in the report of each CI job
Composer2.10.3http:composer of mise.toml, with the sha256 of composer.phar
Python3.14.8python of mise.toml, the pin of kit; make install installs it with mise at the digest of mise.lock, and every check that runs Python runs its shim var/tools/mise/shims/python3 (scripts/python-toolchain.mts), never the python3 of PATH; no CI job sets up a Python of its own, and the CI job python runs that minor only, which requires-python states
uv, ruff, vulture, alint, golangci-lint, deadcode, govulncheck, cargo-auditthe releases of mise.tomlmise.toml, with the digests of every platform in mise.lock (vulture with its uv lock under .mise/locks); the root ruff.toml, which kit copies, declares the rules and the target version of ruff; no package holds a ruff configuration of its own (T24.5)
makenot pinnedGNU Make 3.81 or later

The shared tools come from the npm package @polyspec/kit. make install-tools TOOLS=npm runs the bootstrap installer that kit copies into scripts/kit/bootstrap/: it installs the npm of packageManager into var/tools/npm and writes the wrapper scripts var/tools/bin/npm and var/tools/bin/npx. make install-packages installs package-lock.json with that npm, @polyspec/kit included, whose kit.mk the copy scripts/kit/kit-bootstrap.mk includes; until the package is installed, only these two targets run. make install-tools TOOLS=npm starts with any node of the machine, installs mise from its release archive, checked by the sha256 of config/toolchain.json mise, then the node of mise.toml with it, and the npm wrappers run npm with that node; every later step runs the node of the checkout (kit K171). make install then runs make install-tools (node_modules/@polyspec/kit/dist/install-tools.mjs), which installs every tool of mise.toml with mise install --locked at the digests of mise.lock into var/tools/mise, then the build backend of pythonBackend and the PHP lint of kit; it takes no tool names. make toolchain-lock writes mise.lock for linux-x64, linux-arm64, macos-arm64 and macos-x64 after a change of mise.toml. A recipe never runs the Go or the Python of the machine, and the npm, Go and Python of the machine are not changed. The kit.mk of kit runs every recipe with the environment of mise of the checkout and puts var/tools/bin and the shims of mise, var/tools/mise/shims, first on PATH; the bootstrap writes var/tools/go.mod, so the Go sources under var/tools are a module of their own that ./... does not walk (kit K135). The Makefile and exports GOTOOLCHAIN=local, so go never downloads another toolchain, and GOCACHE=var/go/cache, so go builds into the build cache of the checkout and never into one that other checkouts and runs share. GNU Make 3.81 starts a recipe line without shell syntax itself and finds its program with the PATH that make started with, not the exported one, also when SHELL names another shell; a recipe therefore starts each tool of the checkout by its absolute path, node as $(NODE) and npm as $(NPM) of kit.mk and go as $(GO), its shim, and gofmt only in lines with shell syntax, which the shell runs with the exported PATH; make kit-check fails a recipe that starts a tool of the checkout by its bare name (kit K171). tests/scripts/toolchain-files.test.mts, which make owner-check runs for every change, fails with the expected and the actual version when Node.js, npm, Go, Rust, the PHP minor version or Composer of the recipes differs from these files; make toolchain-check compares every tool of mise.toml with its declaration.

PHP is pinned by its minor version: setup-php, which installs PHP in CI, installs the latest patch of a minor version and cannot pin a patch. Each run of make ci-targets records the versions it ran on, the PHP patch included, in record.json and summary.md of its report, which every CI job uploads. Composer is pinned exactly in mise.toml, and setup-php installs no tools (tools: none in the workflows). make is not pinned: the Makefile uses no construct newer than GNU Make 3.81, and make 3.81 and GNU Make 4.4.1 printed the same commands for every target (T17.1-4). The CI jobs run on ubuntu-24.04, pin every action by its commit and install with make install-tools TOOLS=npm, make install-packages and make install; no job sets up a Go or a Python of its own.

The PHP extension packages/php-ext is an implementation in C. make ext builds it with phpize, configure and make of the php-config on PATH, which must belong to the php on PATH that runs the tests, for every PHP version of config/toolchain.json; CI installs both with setup-php. make ext-arginfo generates its arginfo header from the stub with gen_stub.php of the PHP build.

Commands ​

sh
make install
make help
make check
make rerun-failed
make owner-check

make owner-check runs the owner checks of the changed paths: the uncommitted changes, the paths of PATHS, or the paths changed since BASE. config/owner-checks.json declares, for globs of paths, the make targets and node test files that own them, and the tests that run for every change (always). inputs of the same file declares, for each check target, the globs of the paths that it reads, such as packages/go/** for lint-go and packages/compiler/** for conformance-generated-go; every path of an input needs a rule that selects the target or a target that runs it as a prerequisite, so a change of the path runs every check that reads it. Before any owner runs, the script fails on a tracked path that no rule owns, on an input path whose rules do not select its target, on a glob that matches no path, on a target that is not in the Makefile and on a target that runs the full suite; each failure names the path, the glob or the target. A rule with variable passes its changed paths to its targets: a change under tests/cases runs make conformance-cases CASES="<paths>", which runs the AST conformance and the four generated conformance runners for those cases only, while the conformance targets of make check run every case.

make install runs make install-tools, which also installs the Rust toolchain that rust-toolchain.toml names with rustup toolchain install --no-self-update and changes nothing when it is installed (kit K151), and make cargo-downloads-fetch, which downloads the crates of every Cargo.lock. The Makefile exports RUSTUP_AUTO_INSTALL=0, and the CI workflows set it, so rustup never installs a toolchain on the first cargo: the test files of one node --test run start cargo at once, and their concurrent installs broke each other (T18.7-1); a missing toolchain fails with rustup's message toolchain '...' is not installed, which names rustup toolchain install. The root package.json declares the JavaScript packages of packages/ as npm workspaces and holds the development tools that the root runs, while a package holds those that only it uses, the CodeMirror peers of packages/codemirror and vsce of packages/vscode, whose script package:vsix make vscode-package runs; npm links each workspace package into node_modules and the commands of the packages into node_modules/.bin, where the recipes start TypeScript, tsup, esbuild, Vitest, Playwright, VitePress and vsce (scripts/tools.mts for the scripts), so a package reads the build of another package where the build writes it (T24.2). make install also runs scripts/python-wheelhouse.mts, which downloads the build requirements that packages/python/build-requirements.json pins by SHA-256 into var/python/wheelhouse; the package install check builds the Python wheel offline from that directory.

make build-ts, make build-language, make build-lsp, make build-codemirror and make build-vscode run scripts/build-package.mts --package <package>, which builds the package only when the hash of its inputs differs from the one recorded in packages/<package>/dist.inputs.json; otherwise it prints that dist is current, so one make check builds each package at most once. The inputs are the sources and configuration of the package, its manifest, the root package-lock.json, the installed copies of the packages of this repository that it depends on, and the version of Node.js. tsup bundles the modules of a TypeScript package, and the TypeScript compiler writes its declarations (tsc --emitDeclarationOnly with tsconfig.json and tsconfig.node.json for template-ts, tsconfig.build.json for the other packages) into the same directory, one .d.ts per source file under the directories of the sources; the declaration build of tsup calls the JavaScript API of the compiler, which TypeScript 7 does not have. A build writes into packages/<package>/dist.next-<pid> and moves each file into dist with a rename, a file after the relative modules it imports, so the entries move last; then it removes the files of the previous build that the new one does not have. A reader of dist, such as another repository that copies a package or the next target of a run, never finds a file missing. Limit: a reader that reads several files while a build of changed sources publishes can combine files of the two builds, for example an entry of the previous build whose chunk the new build removed; a rebuild of unchanged inputs writes the same files. With --install, the npm copy in node_modules is installed again only when its files differ from the package, and in the same way: each file that differs is written into node_modules/<name>.next-<pid> and moves into the copy with a rename, a file after the relative modules it imports and package.json last, then the files that the package no longer has are removed, so a reader of the copy, such as another test file of the same node --test run, never finds a file missing. A change of the dependencies of a package is a change of package-lock.json: the install then fails and names npm install as the fix. tests/scripts/build-package.test.mts builds and installs the packages that it depends on itself, so it passes on a checkout where nothing was built.

make check runs, through the guard described below, push-gate-commit, kit-check, layout-check, typecheck, lint-python, docs-check, docs-static-check, test-scripts, rules-check, editor-boundary-check, compiler-interface-check, parity-declarations-check, parity-check, dependency-audit, contract-check, function-contract-check, function-inventory-check, benchmark-check, lint-ts, lint-go, lint-rust, lint-go-format, lint-rust-format, lint-showcase-format, lint-showcase-warnings, lint-php, lint-php-format, test-ts, test-language, test-lsp, test-codemirror, format-check, test-vscode, test-vscode-integration, test-go, test-rust, test-php, test-python, conformance-ts, conformance-go, conformance-rust, conformance-php, conformance-python, conformance-generated-ts, conformance-generated-go, conformance-generated-rust, conformance-generated-php, conformance-generated-python, delimiter-matrix, generated-native-check, test-ext, typed-generator-compile-check, install-check, test-browser, showcase-check, benchmark-smoke and docs-verify-idempotent. The release layers among them, such as install-check, test-browser, showcase-check and benchmark-smoke, are part of make check because a change that passes make check must not fail the release matrix. Every target of the full suite and of its prerequisites runs at most one command: a target with several checks names them as prerequisites, such as lint-go and lint-php of lint, and the Makefile sets MAKEFLAGS += -k, so make runs every check after a failed one and fails at the end; a prerequisite that fails keeps the targets that depend on it from running. The scripts that check each language (scripts/check-generated-*.mjs, scripts/check-typed-generator.mts, scripts/check-package-installs.mts) run every language through checkLanguages of scripts/language-checks.mts and name every language that failed. tests/scripts/failure-accumulation.test.mts checks these rules. lint runs the lint targets of kit with the configuration that kit copies (T24.6): lint-ts runs oxlint with oxlint-tsgolint (.oxlintrc.json) and knip (knip.json), lint-go runs golangci-lint (.golangci.yml) and deadcode in each Go module, lint-rust runs clippy in each Cargo workspace, lint-php runs phpcs (phpcs.xml.dist) and PHPStan (phpstan.dist.neon), and lint-python runs ruff (ruff.toml) and vulture. The format checks of template are lint-go-format (gofmt), lint-rust-format and lint-showcase-format (cargo fmt --check) and lint-php-format (Pint); lint-showcase-warnings compiles the Rust showcase adapter with warnings as errors. test-rust and test-ext run clippy with warnings as errors on the Rust crate and on the PHP extension.

make check runs once per committed tree, when no task of docs/plans/execution-checklist.md is [~]. Before any step it starts node_modules/@polyspec/kit/dist/full-run.mjs, which prints its decision with the reason ([full-run] run: ... or [full-run] refuse: ...) and refuses with status 1:

  • while a task row of the checklist is [~]; the refusal lists each active ID with its task;
  • while the pre-push hook is not installed: core.hooksPath is not .githooks or .githooks/pre-push is not executable; the refusal names make hooks as the fix;
  • while tracked files have uncommitted changes or untracked files are not ignored, because the record names the tree, which holds neither;
  • when var/full-run.json records a full run of the current tree (git rev-parse HEAD^{tree}); the refusal names that run with its commit, its start time and its result;
  • while the process of an incomplete record still runs;
  • while the guard of another run holds var/full-run.lock, the holder lock of the record (node_modules/@polyspec/kit/dist/holder-lock.mjs), which a guard takes before it reads the record and releases after its last write, also when a target throws, so two guards never decide on the same record or write it in turn; the refusal names the holder, and node node_modules/@polyspec/kit/dist/holder-lock.mjs clear var/full-run.lock removes a lock whose process has ended.

The guard runs each target of CHECK_TARGETS with make -k <target> to its end, also after a target fails, and prints [full-run] start <target> (<n>/<total>) and [full-run] <target> passed|failed in <seconds> s; no target has a time limit. It writes var/full-run.json before and after each target: the tree, the commit, the process, the start and end times, the result (incomplete until the last target ends, then passed or failed), the failed targets and each target with its status (pending, running, passed, failed), its times and its elapsed milliseconds. A run that is stopped therefore stays recorded as incomplete, with the target that was running. var/ is ignored by Git, so each checkout and worktree has its own record. A commit that changes the tree permits a new full run when no task is [~].

make rerun-failed reruns only the targets of the current tree that did not pass: the failed targets and the targets that an incomplete run did not finish. It is refused like make check for a task in progress, a pre-push hook that is not installed, uncommitted changes and a running process, and also when there is no record, when the record belongs to another tree and when the full run of the tree passed. It writes each rerun into reruns of the record; when every target has passed, the result of the tree becomes passed.

Development runs unit tests only, and no full or end-to-end check runs locally, neither during development nor before a commit or a push: make check, make rerun-failed, make owner-check, the conformance runners, the browser tests and the VS Code integration test do not run locally, and CI runs the full suite. The CI workflow (.github/workflows/ci.yml) runs on each push to main and on each manual run (workflow_dispatch): each job runs its targets with make ci-targets, every target of CHECK_TARGETS in exactly one job (tests/scripts/suite-parity.test.mts), and every job uploads its report of the logs, the failures and the toolchain versions (var/report).

A push happens only when no task is [~]. Every make invocation of a checkout that tracks .githooks/pre-push sets core.hooksPath to .githooks, the tracked hooks; make hooks sets it, writes the pre-push hook and checks the hooks, and make hooks-check fails while core.hooksPath is not .githooks or .githooks/pre-push is missing, not executable or changed. The pre-push hook runs node node_modules/@polyspec/kit/dist/push-gate.mjs hook, which reads the checklist of each pushed commit (git show <commit>:docs/plans/execution-checklist.md) and of the working tree. It refuses the push while a task is [~], naming each task with the pushed ref and commit or with the working tree and stating that the tasks [ ], [o] and [!] do not block, and also when it cannot read a checklist, for example of a commit that has none, or when the Korean checklist differs from the English one in task IDs or states; the deletion of a remote branch reads only the working tree. make push-gate-commit runs node node_modules/@polyspec/kit/dist/push-gate.mjs commit HEAD (COMMIT names another commit) and fails while a task of that commit is [~] and when the commit does not track .githooks/pre-push with mode 100755; it prints each line as a GitHub annotation and writes it into the job summary, and the job docs of ci.yml runs it on the pushed commit. A push that did not run the hook, from a clone without it or with hooks disabled, therefore gets a failed run that names the tasks.

Version 0.x works on main directly (AGENTS): there is no pull request, merge queue or GitHub ruleset. A checklist row becomes [o] when its owning unit test passes, and main is pushed once, when every row is [o]. The run of .github/workflows/ci.yml on that push is the evidence of the commit: its last job ci-passed needs every other job of the workflow, runs after each of them also when one failed, was skipped or was cancelled (if: ${{ always() }}) and runs make ci-passed RESULTS='${{ toJSON(needs) }}' (node_modules/@polyspec/kit/dist/ci-passed.mjs), which prints the result of every needed job and fails unless each one is success; its step has no condition of its own, because a skipped step would pass the check. A job added to ci.yml is listed in needs, and tests/scripts/toolchain-files.test.mts fails when ci-passed is missing, is not the last job, lacks if: ${{ always() }}, does not need every other job, runs on another runner or runs another step (T22.1-3). A newer push does not cancel the run of an earlier push. The workflow .github/workflows/pages.yml publishes the documentation site of every push to main, and also on a manual run (workflow_dispatch); .github/workflows/dependency-review.yml runs on its schedule and on a manual run, and .github/workflows/release.yml runs only on the push of a tag v* or **/v* and releases it (docs/operations/publication.md).

test-go, test-rust, test-php and test-ext run go test, cargo test and PHPUnit through node_modules/@polyspec/kit/dist/run-tests.mjs, test-ext with --php-extension, which loads the built extension into the PHP of PHPUnit; test-ts, test-language, test-lsp and test-codemirror run vitest and test-vscode runs node --test through it. The runner prints each test when it starts, every 5 s while it runs and when it passes, fails or is skipped, with its elapsed time, and stops the tool when a test outlives its own timeout (30 s, --timeout <seconds>); no package, file or whole run has a time limit. cargo test runs one test at a time, so each test prints its start before its result. PHPUnit also stops a test after 10 s (enforceTimeLimit, failOnRisky). A Go package that does not build prints its compiler output and ✖ build of <package> failed, and the summary line names the compiler errors. A run in which no test ran fails with no test ran; a test does not skip itself when what it needs is not built, it builds it, and the conformance runners run every language unless --langs names them, failing on an absent package. test-scripts runs the tests of the runner in tests/scripts/.

Single-language commands:

sh
make test-ts
make test-go
make test-rust
make test-php
node tests/runner/conformance.mts --langs ts,go
make parity-check

Change procedure ​

  1. Change the specification in docs/spec/ and its .ko.md file.
  2. Add or change the fixture cases in tests/cases/ and the expression fixtures in tests/fixtures/expr/.
  3. Add a failing test in the package, change the code, and keep the test.
  4. Update docs/features.md, docs/features.ko.md, CHANGELOG.md and CHANGELOG.ko.md.
  5. Run the Red and Green tests that own the change and make docs-check.
  6. Commit with a message that names the action, the subject and the object.