diff --git a/AGENTS.md b/AGENTS.md index eb69aa5aa..e2d6a1643 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,1355 +8,90 @@ This repository is a monorepo with two code bases: | `nanvm-lib/` | Rust | NaNVM, the native FunctionalScript VM | Issues live in `todo/` directories, **not** on GitHub. Check them for existing -work before starting — see [todo/README.md](./todo/README.md). +work before starting. -## Contents - -1. [Development environment](#1-development-environment) -2. [Everyday workflow](#2-everyday-workflow) -3. [Testing and proof coverage](#3-testing-and-proof-coverage) -4. [Documentation](#4-documentation) -5. [Design principles](#5-design-principles) -6. [Coding style](#6-coding-style) -7. [Issues (`todo/`)](#7-issues-todo) -8. [Pull requests](#8-pull-requests) - ---- - -## 1. Development environment - -### 1.1 What to install - -| Tool | Version | Required for | -| -------- | -------------------- | ------------------------------------------------------------------ | -| Node.js | **latest** (22 min.) | Everything. | -| Rust | **latest** | NaNVM (`nanvm-lib`) development only. | -| Deno | latest | Updating dependencies; an alternative test runtime. | -| Bun | latest | Updating dependencies; an alternative test runtime. | - -[docker/Dockerfile](./docker/Dockerfile) sets all of this up and is the easiest -way to get a known-good environment. - -### 1.2 Installing dependencies +Run the full check set before submitting: ```bash -npm ci # Node dependencies -cargo fetch # Rust dependencies -``` - -### 1.3 Node test-runner compatibility - -External test registration automatically uses an inline compatibility strategy -below Node `26.0.0`, so `node --test` and `npm run cov` correctly handle -`throw`-tagged tests on Node 22. Node `26.0.0` and later use the native -`expectFailure` strategy and remain the fully supported native baseline. - -### 1.4 Ways to run the FunctionalScript test suite - -Every row below runs the same suite; pick the first one that fits your -environment. - -| Command | Runtime | Needs internet | Notes | -| --------------------------------------- | -------- | -------------- | ---------------------------------------- | -| `npm test` | Node 22+ | no | `tsc` + the repo's runner. | -| `npm start test` | Node 22+ | no | The repo's runner, no type-check step. | -| `node --test` | Node 22+ | no | Node's native test runner. | -| `npm run cov` | Node 22+ | no | `node --test` plus coverage. | -| `deno task fjs test` | Deno | no | The repo's runner under Deno. | -| `deno task test` / `deno task cov` | Deno | no | Deno's native test runner / coverage. | -| `bun fjs/module.mjs test` | Bun | no | The repo's runner under Bun. | -| `bun test` | Bun | no | Bun's native test runner. | -| `fjs test` | Node 22+ | to install | After `npm install -g functionalscript`. | -| `npx functionalscript test` | Node 22+ | yes | No install step. | -| `deno run -A npm:functionalscript test` | Deno | yes | No install step. | -| `bunx functionalscript test` | Bun | yes | No install step. | - -The last four rows run a **published** FunctionalScript rather than this working -tree's version. `npx`, `deno run`, and `bunx` resolve the latest release each -time; `fjs` runs whatever you installed globally, which goes stale as new -versions ship — re-run `npm install -g functionalscript` to update it. - -Deno needs explicit permissions: `-A` is the short form, or pass the same set as -the `fjs` task in [deno.json](./deno.json) (`--allow-read --allow-write ---allow-env --allow-net --allow-sys`). Deno also holds back very recently -published versions; add `--minimum-dependency-age=0` to force the newest. - -CI exercises these same combinations — see the `node22`, `node24`, `node26`, -`deno`, and `bun` jobs in -[.github/workflows/ci.yml](./.github/workflows/ci.yml) for the exact commands -and pinned runtime versions. - -To run only the tests under a subtree, `cd` into that directory and run the -runner from there (e.g. `cd fjs/base64 && fjs test`). Module discovery starts at -the current working directory, and results are reported per test. - -### 1.5 Updating dependencies - -To bump an npm devDependency version, edit `package.json` by hand first (there -is no `npm-check-updates` step anymore). Then run: - -```bash -npm run update -``` - -This requires **Node, Deno, and Bun to all be installed**: `package-lock.json`, -`deno.lock`, and `bun.lock` are all under Git control, and the update refreshes -each of them (plus the generated CI workflow) to match whatever versions are -currently declared in `package.json`. - -### 1.6 Rust commands - -```bash -cargo test # test the nanvm-lib crate -cargo clippy # lint -cargo fmt -- --check # verify formatting -``` - ---- - -## 2. Everyday workflow - -1. Find or file the issue in `todo/` ([§7](#7-issues-todo)). For anything - non-trivial, make sure it contains a concrete design first. -2. Write the code, plus a co-located proof for every new `.f.mjs` module - ([§3](#3-testing-and-proof-coverage)). -3. Run `npm run update` after changing source code. -4. Run the full check set before submitting: - ```bash - npx tsc # type-check with the repo's TypeScript - fjs test # or any equivalent from §1.4 - cargo test # only if you touched Rust - cargo clippy - cargo fmt -- --check - ``` -5. Delete the `todo/` issue file in the same PR that fixes it. -6. Open the PR. Its title and description become the commit message on `main`, - so write them as one ([§8.5](#85-commit-messages)). If it changes code, add - the CHANGELOG entry using the real PR number ([§8.3](#83-changelog)) — PRs - that only touch `todo/`, `AGENTS.md`, or other documentation don't need one, - and say `Changelog: none` in the description instead. - ---- - -## 3. Testing and proof coverage - -### 3.1 Commands - -- `npx tsc` — type-check using the repository's version of TypeScript. -- `fjs test` (or any equivalent from [§1.4](#14-ways-to-run-the-functionalscript-test-suite)) - — test FunctionalScript (`.f.mjs`) files. -- `cargo test`, `cargo clippy`, `cargo fmt -- --check` — the Rust crate. - -### 3.2 Proof coverage is mandatory - -New FunctionalScript modules and functions must have **100% proof coverage** -across every dimension: every exported function called, every line executed, and -every branch (both sides of each conditional) taken. This applies to authored -FunctionalScript source, `.f.mjs` -([`fjs/fsc/README.md`](./fjs/fsc/README.md) defines the extensions). A new -implementation module ships with a co-located proof (its `proof` export) that -exercises all of its exports along all code paths — partial coverage of new code -is not acceptable. If a line or branch genuinely cannot be reached, restructure -the code so it isn't there rather than leaving it uncovered. - -An implementation is `module.f.mjs` and its proof is `proof.f.mjs`. Stage 1 of -the TypeScript-to-JavaScript migration is complete: no authored implementation or -proof `.f.ts` remains, so write both files as JavaScript with JSDoc. Authored -`types.ts` companions may remain permanently and hold the type-level API. - -Proof discovery and coverage follow the same extension: `shouldLoad` in -[`fjs/dev/module.f.mjs`](./fjs/dev/module.f.mjs) matches authored -FunctionalScript source, and both `npm run cov` and `deno task cov` include -`module.f.mjs`. Ordinary (non-FunctionalScript) `.mjs` files stay opt-in through -the `proof.mjs` filename convention. - -A `proof.f.mjs` is authored `.f.mjs` like any other. Its relative **runtime** -imports must target `.f.mjs` modules. Type-only APIs may live in an authored -`types.ts` companion and are referenced directly through that real source path. -Its leading module JSDoc block may include, for example: - -```js -/** - * ... - * - * @module - * - * @import { Phantom } from '../phantom/types.ts' - */ -``` - -JSDoc `@import` introduces no runtime dependency; a `types.ts` file naming the -same path from TypeScript uses `import type` instead. A type that several modules -need independently of one implementation belongs in `types.ts`, not in a JSDoc -typedef that consumers would have to reach into the implementation for. Never add -a runtime value for a TypeScript-only declaration such as `declare const`. -Compiler support remains independent of this JavaScript/JSDoc rule. See -[`fjs/fsc/README.md`](./fjs/fsc/README.md) for the extension contract and module -policy. - -### 3.3 Use `assert` / `assertEq`, never a hand-written `if`/`throw` - -Assert results in `proof` code with `assert`/`assertEq` from -[`fjs/asserts/module.f.mjs`](./fjs/asserts/module.f.mjs), not a hand-written -`if (cond) { throw ... }`. - -A local `if`/`throw` in a test is itself a new branch for the coverage tool to -track, and its failure side is normally never exercised (the test is expected to -pass), so it lands as a permanently-uncovered branch in the very module meant to -close coverage gaps. `assert`/`assertEq` push that branch into a shared helper -whose own branches are already fully covered elsewhere, so the call site adds no -new uncovered branch. - -### 3.4 Assert type-level facts with `Assert>` - -To prove that a type resolves to what you claim, write -`type _Name = Assert>` — `Assert` from -`fjs/asserts/types.ts`, `Equal` from `fjs/types/ts/types.ts`. A wrong -claim is then a compile error (TS2344, "Type 'false' does not satisfy the -constraint 'true'"), and the check costs nothing at runtime. - -Do **not** state the claim as `true as _Predicate`, where `_Predicate` is a -conditional type resolving to `true` or `false`. That proves nothing: -TypeScript compares an assertion against the *widened* type of its operand, so -`true as false` — and `true as never` — are both legal, and the assertion -compiles no matter what the predicate resolved to. Such an entry in a `proof` -object is doubly inert: the runner only invokes functions, so a boolean leaf is -never counted as a test either. - -### 3.5 Never use `try`/`catch`; test throwing with the `throw` key - -Never use `try`/`catch` in `.f.mjs` files — FunctionalScript itself has no -`try`/`catch` and isn't planning to add it soon. To test that a call throws, -nest the test function under a `throw` property key instead of wrapping it in -`try`/`catch` (see `fjs/asserts/proof.f.mjs`). - -The test runner (`fjs/emergent_testing/module.f.mjs`) treats `throw` as a -structural marker: any function reachable under a `throw` key gets -`throws: true`, and the runner inverts the sandboxed result so a thrown error -counts as a pass — with no manual `caught`/`threw` flag or `assert` needed. - -Treat `throw` in FunctionalScript as a panic (like Rust's `panic!`, Go's -`panic`, or Java/C#'s unchecked `RuntimeException`), not as a language-level -`Result`/checked-exception value: nothing in FunctionalScript can catch it, so a -thrown payload is never pattern-matched or branched on by other FunctionalScript -code, and a correctly working program should never throw at all. Recoverable -failure belongs in `Result` (`fjs/types/result`), which callers actually -destructure and is worth asserting on precisely; a `throw`'s payload is read -only by a human or external tooling after something has already gone wrong, so -don't over-invest proof effort in checking its exact value — whether it threw is -normally the part of the contract that matters. - ---- - -## 4. Documentation - -Use JSDoc for module documentation in both JavaScript and TypeScript source. -The `@module` tag belongs only to a package's entry-point file — `module.f.mjs` / -`module.mjs` — not to `proof.f.mjs`, `types.ts`, or any other file. A `module.*` -file starts with one module JSDoc block carrying `@module`, followed by one blank -line before the first source-level import or declaration. A `proof.*` or other -non-`module.*` file has no `@module` tag and no required leading documentation -block; one is still needed if the file has `@import` tags to hold, per below. - -Group all module-level `@import` tags into one leading JSDoc comment block — the -same block as `@module` in a `module.*` file, or a standalone block at the top of -the file otherwise — then put one blank line before runtime imports. Do not -scatter `@import` tags as separate comments between or after individual `import` -statements. External or built-in runtime imports come first, followed by -repository-owned relative `.mjs` runtime imports, with one blank line between the -groups: - -```js -/** - * <...Module documentation...> - * - * @module - * - * @import ... - * @import ... - */ - -import ... from 'node:...' -import ... from 'package' - -import ... from '...mjs' -import ... from '...mjs' -``` - -A non-`module.*` file (e.g. `proof.f.mjs`) with `@import` tags but no `@module` -uses the same grouping without the tag: - -```js -/** - * @import ... - * @import ... - */ - -import ... from 'node:...' -import ... from 'package' - -import ... from '...mjs' -import ... from '...mjs' +npx tsc # type-check with the repo's TypeScript +fjs test # or any equivalent runner +cargo test # only if you touched Rust +cargo clippy +cargo fmt -- --check ``` -Authored TypeScript you write is `types.ts`. Its imports are all type-only, so it -needs no grouping: `import type` names the same real source paths, whether the -type comes from another `types.ts` or from a `.f.mjs` module. - -```ts -import type ... from '../other/types.ts' -import type ... from './module.f.mjs' -``` - -There are no exceptions left: `types.ts` is the only authored TypeScript in -the repository. The former exception — the `fjs/emergent_testing/scenarios` -fixtures and the `all.test.ts` entry, whose `.ts` extension proved that Node, -Bun and Deno execute a TypeScript proof natively — was retired in -[#1520](https://github.com/functionalscript/functionalscript/pull/1520): the -scenario suite never ran in CI and is deleted, with recreation documented in -[`fjs/emergent_testing/scenarios.md`](./fjs/emergent_testing/scenarios.md), -and the test entry is authored `all.test.mjs`. - -The runtime-import grouping applies to repository-owned relative imports, not to -external or built-in modules: a FunctionalScript module may depend at runtime on -external modules and on repository `.mjs`, and there is no relative runtime `.ts` -import group at all. The blank line after the leading JSDoc block is required -in every file that has one — `module.*` (even with no `@import` tags), -`types.ts`, and `proof.*` alike; it keeps the header detached from the first -import/declaration and preserves it through declaration emit. `types.ts` is -the most easily missed case, since its emitted `types.d.ts` is the published -documentation for the whole type-level API. - -Where each kind of documentation belongs: - -| Content | Home | -| ------------------------------------------------ | ----------------------------------------- | -| API shape and invariants | JSDoc on `module.f.*` exports or `types.ts` | -| Architectural choices, *why this / why not that* | the relevant `README.md` | -| What changed in a release | `changelog/` (short, see §8.3) | -| Rationale, measurements, alternatives considered | the PR description | - ---- - -## 5. Design principles - -### 5.1 Simplicity first - -**Always prefer simplicity and quality over optimization.** Never optimize -prematurely, and especially never at the cost of simplicity. A simple, correct, -generic solution comes first; optimization work starts only after confirming it -is actually needed (a measured problem or a real limit being hit, not a hunch), -and even then it is a **separate task**: file it as its own `todo/` issue instead -of folding it into the current change. - -When that task is taken up, still solve the problem in a generic way — improve -the algorithm, the data structure, or the API — instead of hacking special cases -into an otherwise general design (byte-prefix sniffing instead of real parsing, -key-order assumptions, hardcoded fast paths). A documented implementation limit -that a later generic improvement can lift (e.g. a size bound on a buffering -parser) is an acceptable interim answer; a semantic assumption baked into a -format or contract for speed is not. - -### 5.2 The API is the most important part of quality - -**Quality is the main priority, and the API is the most important part of it.** -A clean, readable, simple API for the modules that consume it is worth more than -any existing API's shape. **If the new version can have a better, simpler API, -change it — never hesitate.** An API kept only because something already calls it -is how a codebase ends up with a heap of legacy nobody is allowed to modify, and -every later design is then bent around it. Never cut corners, hack, or bend a -caller's input/output to fit an existing API's shape just to avoid touching that -API. - -When the existing design is the obstacle, **fix the design**: rewrite the API and -make a breaking change, updating every importer in the same PR (see -[§8.4](#84-breaking-changes-and-versioning)). Every consumer inside this -repository is visible and updatable, so a hard cutover is nearly always -available — take it. Adjusting a call site to work around a poor API, instead of -improving the API, is the wrong trade-off here. - -Keeping the old API alongside the new one is a **last resort**, not the -convenient middle path: two shapes for one concept doubles what a reader has to -understand and, in practice, the old one never leaves. If a rewrite is genuinely -too large for one PR, split it by **scope** — module by module, each step its own -complete breaking change — rather than by **time**. If a transitional API is -still unavoidable, file a `todo/` issue for removing the old one as part of the -same change; the work isn't done until that issue is deleted. - -**If you see a way to improve an API — or a new API that would make consuming -modules simpler and more readable — propose it as soon as you notice it.** Don't -defer or silently work around it. File a `todo/` issue with a concrete design -(see [§7](#7-issues-todo) and [todo/README.md](./todo/README.md)) so it can be -reviewed promptly; if the improvement is in scope for what you're already doing, -raise it before building on top of the weaker design. - -### 5.3 Design before implementation - -- Before implementing a non-trivial feature, ensure the corresponding issue - document in `todo/` contains a concrete design. If the issue exists but the - design is absent, vague, or contradicts the codebase or runtime behavior, - update the issue first and wait for review — do not write code against an - incomplete or incorrect design. -- When a discrepancy is found between an issue's design and reality (a missing - API, a wrong environment variable, an incompatible type), correct the design - document and surface the problem rather than silently working around it. -- Before relying on an undocumented or assumed runtime behavior (environment - variable names, API shape, framework detection), verify it with a small test or - source check rather than assuming. - -### 5.4 Reuse, DRY, and separation of concerns - -- **Reuse code.** -- **Don't Repeat Yourself (DRY)** — a core principle of FunctionalScript, not - just a stylistic preference. When two or more modules share an algorithm and - differ only in constants, alphabets, or small helpers, extract a parameterized - factory into a shared module rather than copy-pasting. Combined with the - previous point: only extract once the second real consumer exists. -- **Separation of concerns** — move logic to its natural module even with a - single consumer when the logic is conceptually distinct (e.g. path manipulation - belongs in `fjs/path`, not inline in a loader). First search for an appropriate - existing module; create a new one only if no good fit exists. This is different - from DRY extraction: it is always appropriate. -- **Avoid side effects and mutability.** - -#### Exception to DRY: performance measurement - -Time measurement must capture immediately after an operation completes to avoid -measuring the wrapper code itself. This naturally leads to duplication when both -success and error paths must measure. Readability is more important than -eliminating the duplication — keep each measurement explicit and close to its -operation: - -```ts -sandbox: async (f: () => T) => { - let result: Result - let after: number - const before = performance.now() - try { - const value = await f() - after = performance.now() - result = ok(value) - } catch (e) { - after = performance.now() - result = error(e) - } - return { result, duration: after - before } -} -``` - -Why this pattern is good: - -- The two `after = performance.now()` calls are necessary on the critical path — - extracting them into a helper would measure the helper function's overhead - instead of just the operation. -- TypeScript tracks uninitialized values: declaring `let after: number` without - initialization lets the type checker verify that `after` is assigned in all - code paths before the final `return` statement. -- We still avoid duplication of non-critical computations: the return value of - the function (`{ result, duration: after - before }`) is formed once, not - duplicated. Only the timing capture (which must be immediate) appears twice. - -### 5.5 Declarative over imperative - -**Prefer declarative style over imperative.** When defining tools, handlers, -dispatchers, or similar abstractions, favor data-driven definitions (metadata + -schema + handler together in an array or registry) over imperative switch -statements or hardcoded conditionals. Declarative patterns are easier to extend, -test, and reason about. For example: define tools as an array of -self-descriptive objects (name, description, schema, handler) and dispatch -generically over them, rather than hardcoding a switch on tool name. +Two principles outrank everything else. **Always prefer simplicity and quality +over optimization** — never optimize prematurely, and never at the cost of +simplicity. **The API is the most important part of quality** — if a new version +can have a better, simpler API, change it; breaking changes are the right call +whenever they improve the API. The full set, which governs both code bases, is +[DESIGN.md](./DESIGN.md). -### 5.6 Never precompute a size to predict whether something fits +This file is a map: each section below holds the facts you must not violate and +links to the document that holds the rest. Read a linked document when the task +actually touches its subject. -**Never precompute or estimate an encoding/decoding size to predict whether it -will fit a limit.** Attempt the real decode/encode and branch on its result -instead. Size estimates (string-length lower bounds, base64's 3/4 ratio, -JSON-escaping multipliers, …) are easy to get subtly wrong — and a -wrong-in-the-unsafe-direction estimate reintroduces the exact crash the check was -meant to prevent — while the real operation is always exactly right. - -Express the fallible operation as a `try*` function returning `Nullable` (see -`tryUtf8`, `tryListToVec`, `tryU8ListToVec`, `base64Decode` in `fjs/text`, -`fjs/types/bit_vec`, `fjs/base64`), add a new `try*` variant if one doesn't exist -yet for the operation you need (including effect primitives like `write`), and -have the caller check the `null` result rather than a precomputed bound. - -### 5.7 CLI parameters over environment variables - -CLI parameters are preferred over environment variables when adding new -features. - -### 5.8 Embedded DSLs should reuse host-language syntax - -**An embedded DSL should reuse JavaScript / FunctionalScript values and syntax -whenever their existing meaning is exactly the meaning the DSL needs.** Prefer -ordinary numbers, strings, arrays, and objects over wrapping the same information -in tagged syntax. For example, prefer `3.14`, `'abc'`, `[1, 2]`, and `{ x: 1 }` -over representations such as `['number', 3.14]` or an object/array tag whose only -purpose is to say what the host value already says. - -Introduce a constructor, function, tag, or other DSL-specific form only for a -concept the host language cannot express directly and unambiguously. RTTI follows -this pattern: constants can describe themselves, while constructions such as -`array(number)` need DSL syntax because an array *value* and the type "array of -numbers" are different concepts. The proposed NaNVM operator-test data eDSL applies the same principle: ordinary -operands and expected results should be ordinary JavaScript values, while -references, function values, and expected throws need special forms. - -Do not expose a tagged-union AST as the authoring API merely because it is -convenient for the implementation. The ergonomic eDSL and its normalized -machine-oriented representation may be different layers: a parser/compiler may -normalize an author-friendly value into explicit tagged nodes for pattern -matching, serialization, hashing, or code generation. Prefer the simplest representation that preserves the required semantics. Avoid -redundant DSL syntax: less representational noise benefits people, AI systems, -deterministic computation, hashing, serialization, storage, and code generation -alike. Use a more explicit normalized representation only when that extra -structure provides actual semantic or processing value. - -Apply this principle to new eDSLs and when improving existing ones, including the -future FunctionalScript function AST. That AST should reuse FunctionalScript's -own literals, arrays, objects, and other language constructions wherever their -meaning coincides with the syntax being represented, and introduce explicit AST -nodes only where the host-language value would be ambiguous or insufficient. - ---- - -## 6. Coding style - -### 6.1 Immutability and purity - -- Don't mutate arrays, sets, maps, or objects in place. Avoid `.push`, `.pop`, - `.shift`, `.unshift`, `.splice`, `.sort`, `.reverse`, `Set#add`, `Set#delete`, - `Map#set`, `Map#delete`, and index/property assignment on accumulators. Build - new values with `.map`, `.filter`, `.flatMap`, spread, `new Set([...prev, x])`, - `new Map([...prev, [k, v]])`, and `Object.fromEntries(entries.map(...))`. -- Use `let` variables only within the function body where they are declared. - -#### No regular expressions - -Do not use regular expressions. Express lexical checks and transformations with -ordinary typed functions so their structure, supported characters, and edge -cases remain explicit and independently testable. - -### 6.2 Types - -#### JavaScript/JSDoc type declarations - -Authored `.mjs` / `.f.mjs` files must remain valid JavaScript. Put named and -generic static types in JSDoc rather than TypeScript syntax, and keep public -assignability and declaration-emission behavior intact when a type's spelling -changes. A separately useful type-level API may live in an authored sibling -`types.ts`; that file remains TypeScript type source and holds no runtime -implementation. - -Name implementation-only JSDoc typedefs with a leading `_` -(`/** @typedef {number} _Type */`). Declaration emit cannot strip them yet, so -the underscore — not the emitted `.d.ts` — is what marks a name private, -and renaming or removing a `_`-prefixed alias is not by itself a breaking -change. The public contract still governs transitive effects. See -[Private JSDoc typedefs](./fjs/fsc/README.md#private-jsdoc-typedefs) for the -full rule and examples. - -Use `@typedef` for a named type and `@template` for its type parameters. A -constraint goes in braces before the parameter name: - -```js -/** - * @template {Operation} O - * @template T - * @typedef {(_: Pr[1]) => Effect} Cont - */ -``` - -TypeScript 7 also supports variance modifiers on JSDoc type-alias parameters. -Translate TypeScript `in` / `out` directly on `@template` instead of dropping -the variance annotation. For example: - -```ts -export type Cont = - (_: Pr[1]) => Effect -``` - -becomes: - -```js -/** - * @template {Operation} out O - * @template T - * @typedef {(_: Pr[1]) => Effect} Cont - */ -``` - -The supported forms are `@template out T`, `@template in T`, and constrained -forms such as `@template {Operation} out O`. Variance modifiers belong to type -parameters of a JSDoc type alias (`@typedef`); do not put `in` / `out` on an -ordinary function's `@template`, where TypeScript rejects them. - -When JavaScript needs a type from an authored `types.ts`, put JSDoc `@import` -with that real source path in the leading module JSDoc block; do not create a -separate `@import` comment. For example: - -```js -/** - * ... - * - * @module - * - * @import { Types } from './types.ts' - */ -``` - -Another `types.ts` referring to the same file uses `import type` with that same -path: - -```ts -import type { Types } from './types.ts' -``` - -Both forms are type-only and introduce no runtime import. The `types.ts` file -itself exists and is checked as ordinary TypeScript source, so this convention -does not rely on `.d.ts` substitution and works with Deno's source resolver. - -The fully erased forms are the only permitted ones — for package consumers as -well as repository code. The published package ships `types.d.ts` but no -`types.js` runtime module, and under `verbatimModuleSyntax` only `import type -{ X }` (and JSDoc `@import`) erase the whole statement: the inline form -`import { type X } from '…/types.js'` compiles to a retained `import {}`, as do -`import * as` and bare side-effect imports, and fails at runtime with -`ERR_MODULE_NOT_FOUND`. - -A declaration-only module belongs in `types.ts` rather than acquiring an -artificial JavaScript runtime representation. See [§4](#4-documentation) -for the complete module-header and import-order convention. - -Decide where a type lives by who needs it: one that must survive independently of -a single implementation goes in `types.ts`, while one that is naturally -implementation-local and expressible in JSDoc stays beside the code it describes. -Never invent a runtime import, export, `Symbol()`, or other value solely to -represent a TypeScript-only declaration such as `declare const`. - -When a public type is written in JSDoc, verify both normal type checking and the -emitted `.d.ts` / `.d.mts` declarations. The JSDoc spelling may differ from the -TypeScript one, but the public type contract must not become weaker for being -written in JavaScript. Types authored in `types.ts` use ordinary TypeScript -syntax and declaration emit. - -#### Prefer inference - -Let TypeScript infer the type of private constants, local variables, and return -types of non-exported functions — write `const f = () => () => null` rather than -`const f: TailReduce = () => () => null`. Add an explicit -annotation only when inference gives the wrong type (e.g. a literal that would -widen — covered below), when the inferred type is not precise enough for a call -site, or on `export`ed declarations where the annotation documents the intended -public contract. Annotating things TypeScript already knows correctly adds noise, -couples the annotation to the implementation, and can introduce `as` casts to -paper over mismatches. - -#### Pin literal `const`s - -A `const` with a **literal** initializer (string / number / bigint / boolean / -array / object literal) must pin its type — either an explicit annotation -(`const a: T = …`) or a trailing `as const`. Never rely on TypeScript's default -widening. - -FunctionalScript data is immutable, but stock `tsc` widens literals by default -(`'2.0'` → `string`, `42n` → `bigint`, `[1, 2]` → `number[]`, dropping -`readonly`), which both misrepresents immutable data and silently breaks literal- -and tuple-dependent typing (`Ts<>` over an rtti schema, tagged-tuple -discriminants in the effect system). The rule scopes to literals because a const -assertion is only legal on a literal or enum member (TS1355) — calls, -conditionals, and references (`or(...)`, `option(...)`, a bare `string`) already -carry precise, non-widening types and are exempt. The mistake is invisible at -runtime (the value is correct; only the type widens), which is exactly why it -must be a style rule. - -Example: `const jsonrpc = '2.0' as const` and -`const request = { jsonrpc, method } as const`, but -`const id = or(string, number, null)` needs nothing. - -#### Avoid `as` type assertions - -Avoid `as` type assertions (except `as const`). Treat them like `unsafe` in Rust -— a last resort that bypasses the type system's safety guarantees and must be -justified. They silence the type checker and hide real bugs; if a cast is needed, -it usually means the types or the code structure should be improved instead. - -The JSDoc equivalent, an inline `/** @type {T} */ (expr)` cast, carries the same -hazard and the same rule: avoid it. Prefer annotating a separate `const` -declaration instead of casting an expression inline — - -```js -/** @type {ReadonlyMap} */ -const empty = new Map() -``` - -rather than - -```js -mapSet(/** @type {ReadonlyMap} */ (new Map()), 'a', 1) -``` - -— because the declaration form documents the variable's intended type and lets -the compiler check the initializer against it (closer to `satisfies`), while the -inline form silently overrides whatever the compiler inferred, exactly like `as`. -Inline `@type` casts carried over from `as` assertions during the -TypeScript-to-JavaScript migration still exist in the tree; converting one to the -declaration form is a welcome cleanup wherever the rewrite is straightforward. - -When the value being narrowed is an invariant a comment would otherwise have to -assert on trust — "this is never `undefined`/`null` because the caller already -guaranteed X" — prefer `assert`/`assertNotNullish` from -[`fjs/asserts/module.f.mjs`](./fjs/asserts/module.f.mjs) over a cast: - -```js -const refCounter = assertNotNullish(refs.get(entry)) -``` - -rather than - -```js -const refCounter = /** @type {_RefCounter} */ (refs.get(entry)) -``` - -A cast is a claim the compiler takes on faith and erases at runtime: if the -invariant it documents ever breaks — a future edit to the code it depends on, -a case the original reasoning missed — the narrowed value is silently wrong -instead of the assertion failing where the break actually happened. -`assert`/`assertNotNullish` narrow exactly the same way (via `asserts v` / -a checked return type) but also check the claim every time, so a broken -invariant throws immediately at the point that assumed it, not later at -some unrelated crash site. Reach for a cast only when there is truly no -runtime check to perform — e.g. `@type {const}` below, or narrowing across a -boundary the type system cannot express at all. - -`@type {const}` (the JSDoc equivalent of `as const`, see "Pin literal -`const`s" above) is the one case where this preference inverts: it **must** -stay an inline cast on the expression — -`export const x = /** @type {const} */({ ... })` — and cannot be hoisted to a -leading declaration annotation. `/** @type {const} */` directly above -`export const x = { ... }` makes TypeScript try to resolve `const` as an -ordinary type name and fail with `TS2304: Cannot find name 'const'`; only the -inline-cast position gives it the special const-assertion meaning. This is -unlike every other `@type` cast, which works in both positions — don't -"clean up" a `@type {const}` inline cast into the declaration form. - -#### Prefer `@satisfies` over `@type` when checking, not overriding - -When the goal is to *verify* that an expression matches a shape — not to -*declare* what the compiler should treat it as — use an inline -`/** @satisfies {T} */ (expr)` cast instead of `/** @type {T} */ (expr)`. -`@satisfies` (mirroring TypeScript's `expr satisfies T`) checks assignability -against `T` while keeping the expression's own inferred type; `@type` discards -the inferred type and substitutes `T`, silently absorbing any mismatch instead -of reporting it. If the original TypeScript source used `satisfies`, migrate it -to `@satisfies`, not `@type` — the two are not interchangeable, and swapping one -for the other changes what gets checked. - -This matters most for an expression handed to a generic function, where an -enclosing `@type` cast can strip the very context the function relies on to -check its argument. A cast around a big object literal passed to a -`ToAsyncOperationMap`-shaped parameter, for example, blocks TypeScript from -checking each operation's implementation against `O` — the object literal is no -longer contextually typed by the call site, so a drifted handler shape is -absorbed by the cast instead of flagged. Prefer no cast at all when the callee -already supplies enough context (as `asyncRun(map)` does here) so the object -literal is checked structurally on its own; reach for `@satisfies` only where a -check without adopting the target type is actually wanted, e.g. a value that -must additionally be nominal-branded — `asNominal(x) satisfies T` becomes -`/** @satisfies {T} */ (asNominal(x))`, not `@type`. - -#### Mutually recursive constants: cross-reference with `typeof` - -When exported constants refer to each other in a cycle — the usual shape for a -recursive rtti schema, where `unknown` names `object` and `array` and both are -built from `unknown` — pin them with an explicit `@type` whose element types are -`typeof` references to the other constants, **not** with `@type {const}`: - -```js -/** @type {() => readonly['or', typeof primitive, typeof object, typeof array]} */ -export const unknown = () => ['or', primitive, object, array] - -export const object = record(unknown) -export const array = rttiArray(unknown) -``` - -Forward references are fine: `unknown` is annotated in terms of `object` and -`array`, declared below it. - -`@type {const}` is wrong here even though it compiles. It pins the tuple, so -`npx tsc` and `fjs t` both pass — but it gives declaration emit no *name* for -the recursive positions, so the emitter inlines the structure, gives up at -depth, and writes `/*elided*/ any`. On `fjs/media/json/rtti/module.f.mjs` the -const cast emitted 4 `any` and 2 `/*elided*/`; the `typeof` form emitted -neither. Only a consumer type-checking against the published `.d.mts` sees the -difference, which is why this needs to be a rule rather than something review -catches. Omitting the annotation entirely is a third, louder failure: the array -literal widens to `(string | …)[]` and fails `TS2345` outright (see "Pin literal -`const`s" above). - -Pair the annotation with a round-trip assert so it stays checked rather than -merely claimed — `fjs/media/json/types.ts` holds -`Assert>>`. An explicit `@type` on a constant -whose type the compiler would otherwise infer is only as trustworthy as what -verifies it. - -#### Avoid type predicates - -Avoid TypeScript type predicates (`(x: T): x is U`). They are error-prone: the -compiler trusts the annotation unconditionally, so if the runtime check diverges -from the declared type the error is silent. Use `instanceof` for -class/constructor discrimination, or restructure the union so a structural check -(e.g. `instanceof Array`) narrows correctly without a predicate. - -**Exception:** a type predicate is acceptable when every alternative is -materially worse — in particular when the only other way to narrow is an `as` -cast (which is *unsafe*, strictly worse). Use it only where the predicate body -**is** exactly the structural check that defines membership in `U` (e.g. -`(e: Entity): e is readonly Vec[] => e instanceof Array`), so there is nothing -for the compiler to trust beyond what it could verify itself. Be careful: this -safety is not enforced — if the type definition of `T` or `U` changes later (a -member added to the union, a field's shape changed), the predicate's body can -silently stop matching its asserted type and narrow incorrectly with no compile -error. Keep such predicates next to the type they discriminate, and revisit them -whenever that type changes. - -#### `StringMap` / `RequiredMap` / `OptionalMap` for string-keyed records - -Use the record types from `fjs/types/object/types.ts` for all string-keyed -record types. The key set picks the type: - -- **Open key set:** `StringMap` is `{ readonly[k in string]?: T }` — any - key, every value optional, because "the key may be missing" is what an open - key set means at runtime. -- **Finite key set:** `RequiredMap<'a' | 'b', T>` is - `{ readonly a: T; readonly b: T }`, and `OptionalMap<'a' | 'b', T>` is that - same record with optional values. - -`RequiredMap` is `never`: no object can carry every string as a -required key, so an open key set fails to compile there. Reach for -`StringMap` instead. That guard is `string extends K`, which holds exactly -when `K` is `string` — TypeScript cannot be asked whether a type is finite, so -give `RequiredMap` a union of string literals and nothing else. A template -literal like `` `x-${string}` `` is infinite but passes the guard. - -Do not write inline `{ readonly[k in string]: T }` without `?` — TypeScript -types every access as `T` but the value can be `undefined` at runtime. -**Exception:** mutually-recursive types (e.g. -`type Obj = { readonly[k in string]?: Obj }`) must use the inline form. A type -alias may not reference itself through *another* alias's instantiation, so -`type Obj = StringMap` is TS2456 ("Type alias 'Obj' circularly references -itself") even though it expands to the inline spelling, which resolves. That is -a property of aliasing, not of any one definition — writing the record as a -mapped type rather than a conditional one does not lift it. - -When iterating all defined entries of a `StringMap`, use `definedEntries` -from `fjs/types/object/module.f.mjs` instead of `Object.entries`; use -`definedValues` instead of `Object.values`. - -#### `flatMap` over a filtering type predicate - -Prefer `.flatMap(e => e !== undefined ? [e] : [])` over -`.filter((e): e is T => e !== undefined)` to remove `undefined` entries from an -array. Type predicates in `filter` are error-prone: if the element type changes, -the predicate silently becomes wrong. `flatMap` narrows correctly without a -manual type annotation. - -#### Composition over intersection - -Prefer composition over intersection types. When a type needs an existing record -plus extra fields, embed the record as a named field rather than mixing it in -with `&`. Write `type Signer = { rfc6979: Rfc6979, nf: PrimeField, g: Point }`, -not `type Signer = Rfc6979 & { nf: PrimeField, g: Point }`. - -Intersection blurs where each field came from, couples the composite to the exact -shape of the part, and tempts you to widen the part to fit the whole (e.g. -bolting curve fields onto an `Rfc6979` that is also built and consumed on its own -from a bare subgroup order). A named field keeps the part **unchanged** — -independently constructed and consumed — and reads as plain data you destructure -(`const { rfc6979, nf, g } = signer`). This mirrors the data-first preference -behind avoiding `as` and type predicates: make the structure explicit instead of -deriving it. - -**Exception:** use `&` when every alternative is materially more complex — when -composition would misdescribe the value or push real cost onto callers just to -satisfy the rule. The cases in this repository: - -- **A type-level marker on a value that keeps its own runtime shape.** - `Nominal = symbol & {…}` and - `Phantom = S & { readonly[phantomKey]?: T }` exist precisely because the - value still *is* a `symbol` / an `S` at runtime. A named field would invent a - wrapper that never exists. -- **Describing an object you don't own, or a flat serialized shape.** - `IncomingMessage = Readable & {…}` in `fjs/effects/node/module.mjs` describes - Node's object, which really does carry both member sets on one level. Nesting - the base under a field there would describe something that isn't there — and - for a wire format it would change the encoding, not just the type. -- **A facade adding a member to a generic interface.** - `FileCas = Cas & { url: (v: Vec) => string }` — composition - would route every consumer through an extra hop (`fileCas.cas.read(…)`) to - express one added member. -- **Opening a record type to dynamic keys.** A record type restricts its fields: - unknown keys are neither writable in a literal nor readable off a value. - Intersecting it with `StringMap` keeps the declared fields - checked while allowing arbitrary keys: - - ```ts - type A = { - readonly x: number - } - - // `a` doesn't have access to other fields. - const a: A = { - x: 5, - // b: null, // compilation error - } - // const aB = a.b // compilation error - - // `AM` is a `StringMap` but with restricted fields. - type AM = StringMap & A - - // `am` has access to all fields, with `A`'s restrictions still applied. - const am: AM = { - x: 5, - b: null, - // x: 'no', // compilation error: `x` is still `number` - } - const amB = am.b // `unknown` - ``` - - Reach for this only when the composite type itself must carry both. To hand a - record to something that expects a map, widen at the use site instead — `A` is - already assignable to `StringMap`, so - `const m: StringMap = a` needs no intersection (and no `as`). - -The exception is about cost to the reader or to the runtime, not about `&` being -shorter to type. A composite assembled from record types you define and control — -the `Signer` case above — is still the rule, not the exception. - -#### String literals instead of enum-like aliases - -Use string literals as strongly-typed values directly — don't introduce enum-like -aliases (`enum`, named constants such as `const FOO = 'foo'`) the way other -languages require. TypeScript narrows string literals precisely, so the string -*is* the typed value at runtime. Prefer, in order: - -1. a literal-union type when you only need the type — `type My = 'foo' | 'bar'`; -2. `const my = ['foo', 'bar'] as const` with `type My = typeof my[number]` when - you also need to iterate the values at runtime; -3. `const my = { foo: 'v5', bar: 'v6' } as const` when you need a key→value - mapping (and `keyof typeof my` gives you the key type). - -Existing examples: `os` / `Os` and `architecture` / `Architecture` in -`fjs/ci/common/module.f.mjs`, and `actions` in `fjs/ci/config/module.f.mjs`. - -#### Write the call, not the value it computes - -A value an encoder would produce should be written as that call, not as the -computed result — in source and in proof expectations alike. `range('AF')`, not -`1090519110`. - -The number is derived from an input, and writing it down discards the input that -explains it: nothing recovers `A`–`F` from the digits, and a reader cannot tell a -correct constant from a typo'd one. A named constant does not help — the value is -still hand-computed. This does not apply to numbers that mean themselves: an -index, a count, `0`, `1`. - -When the value sits inside a larger literal, interpolate rather than inline: - -```ts -// avoid -if (r !== '[{"expected":[1090519110]}]') { throw r } -// prefer -if (r !== `[{"expected":[${range('AF')}]}]`) { throw r } -``` - -Tests are the exception where the encoding itself is what's under test. A test -that builds both its input and its expectation from the same encoder cannot -detect a change to it — both sides move together. Some tests may keep -hand-written values to cover that; comment why they are literal. - -### 6.3 Structure and scoping - -#### Import instead of duplicating - -When a sibling module already has the type or helper you need, import it — add -`export` to the existing declaration if it's not yet exported, rather than -duplicating it (e.g. `parse` reuses `Path`, `ValidationError`, `verror`, -`prependPath`, `primitive0Validate`, `constPrimitiveValidate` from `validate`). - -#### Hoist helpers to module scope - -Hoist helpers (functions, types, constants) to module scope when they don't -capture local state — don't redeclare them inside another function on every call. -If a `reduce`/`map` callback needs context that varies per call, thread it -through the accumulator rather than closing over a local, so the step function -itself can live at module scope. - -Treat "doesn't capture local state" as a target to restructure toward, not just a -condition to check: for any nested helper meaningful enough to carry a name, lift -its captures into leading curried parameters and hoist it — even a helper with a -single call site and no per-call cost. A closed, module-scope function has a -context-free identity: content-addressable FunctionalScript can deduplicate -structurally identical closed functions across modules (and repositories), while -a helper that captures enclosing locals hashes uniquely to its context. - -Don't split below the semantic seam, though — if a fragment can't be described by -a one-line JSDoc claim ("renews the lease", "publishes the staging file"), -restructure until it can rather than extracting an unnameable piece. - -#### Hoist call-invariant computations - -If a sub-expression does not depend on a function's parameters, evaluate it once -in the enclosing scope and capture the result instead of recomputing it on every -call. This includes property accesses and destructuring of a module-level value: -prefer `const { listToVec } = msb` at module scope and call `listToVec(x)` over -calling `msb.listToVec(x)` inside a per-call function. - -#### Place curried partial applications at their dependency's scope - -When building a value through a chain of curried partial applications -(`f(a)(b)(c)`), place each partial application at the scope matching what it -depends on. This is not primarily the previous rule's performance concern — it -makes the computation's dependency structure visible: the scope a binding lives -in tells the reader which arguments it needs without tracing the whole call -chain. - -Example (`fjs/basen/module.f.mjs`): `chunkList(msb)` depends on neither `bits` -nor `v`, so it's bound once at module scope (`chunkListMsb`), shared by every -`baseN(...)` codec; `chunkListMsb(bits)` depends on `bits` but not `v`, so it's -applied once inside `baseN`'s body, not once per `vecToString(v)` call. When the -fully-applied chain is itself the thing captured once — assigned directly as an -object property, e.g. -`vecToString: compose(chunkListMsb(bits))(fold(chunkToString)(''))` — naming the -intermediate halves separately adds nothing: the composition already shows, -structurally, that neither operand depends on `v`. Content addressing gives a -second reason beyond readability: each partial application bound at its own scope -is a closed value with its own identity, shareable wherever the same layer -recurs — a monolithic body that re-derives the whole chain per call shares -nothing. - -#### Factor out what two branches share - -When two code branches share most of their structure, refactor so the shared part -appears once and only the difference lives in the conditional. Forcing the reader -to mentally diff two near-identical blocks is a readability cost, not just a DRY -violation. Prefer `{ ...shared, ...(cond ? { extra } : {}) }` over two object -literals that repeat every field, and `cond ? a : b` over duplicated -`if`/`return` arms whose bodies only differ in one expression. Hoist -call-invariant computations above the branch so the conditional contains only -what actually varies. - -#### Prefer destructuring over indexed/property access - -Bind tuple elements and record fields with a pattern -(`const [tag, value] = result`, `const { length, mime_type } = meta`, -`([tag, value]) => …` in a callback) instead of reaching for -`result[0]`/`result[1]`/`obj.field` at each use. It names the parts once, reads -closer to the data's shape, and avoids repeating the container. - -For tagged tuples this is also safe: TypeScript narrows a destructured -discriminated union after a guard on the tag -(`const [tag, value] = result; if (tag === 'error') { … } /* value is the ok payload here */`), -so there is no reason to keep index access for narrowing. **Exception:** when you -genuinely need only one deeply-nested element and a full pattern would be noisier -than a single access. - -### 6.4 Effects (`fjs/effects`) - -Bind every effect in a sequence to its own name, all at one level, so the -sequence reads top-to-bottom in evaluation order instead of inside-out. - -```ts -// avoid -step(a, x => step(f(x), y => step(g(y), z => h(z)))) -// prefer -const x0 = step(a, f) -const x1 = step(x0, g) -return step(x1, h) -``` - -In practice this means not nesting `step` calls from `fjs/effects` — but the -requirement is the *visible sequence*, not the absence of the token `step(` -inside another `step(`. Nesting costs more than indentation: it hides how many -effects run, it puts every continuation's parameter in scope for everything below -it (inviting accidental shadowing), and it makes inserting or reordering a link a -re-indentation of the whole block. - -Lifting a nested continuation into a named helper does **not** satisfy this rule -— `step(a, cont)` with `const cont = x => step(f(x), …)` beta-reduces to the -nested form, so nothing was flattened and the sequence is now split across two -definitions instead of being visible in one. Extract a continuation when it is a -meaningful named operation in its own right, never to relocate a nesting you were -asked to remove. - -#### Reaching back to an earlier value: use `historyStep` - -A later link needing a value from an earlier one is **not** a reason to nest — a -nested continuation only reaches back because it closes over the enclosing scope. -Use `historyStep`, which carries every earlier value forward in a newest-first -tuple (a `History`) so they stay reachable downstream and the chain stays flat. -`history(e)` starts a history from a plain effect; `historyStep` takes a history -and returns one, so it composes with itself to any depth and only the entry point -needs `history`. - -```ts -// avoid — nested only so `h` can still see `x` -step(a, x => step(f(x), y => h(x, y))) -// prefer -const x0 = historyStep(history(a), f) -return step(x0, ([y, x]) => h(x, y)) -``` - -A position in the tuple is distance back from the current link, not evaluation -order, so a destructuring reads reverse-chronologically (`([z, y, x]) => …` binds -`x` earliest). Reaching further back costs an index rather than a traversal, but -a long chain makes the positions hard to count; when that starts to hurt, -collapse it into a record of named fields (`pure({ x, y } as const)`) and start a -fresh history from there. Before reaching for either, check whether the nesting is -forced only by a local declared inside a continuation that doesn't depend on it — -hoist such locals per [§6.3](#hoist-call-invariant-computations) and the nesting -often dissolves on its own. - -#### Why the combinators themselves nest - -This rule governs sequences of effects in **consuming** code, and the combinators -in `fjs/effects` are what make it followable. The nesting has to exist somewhere: -a name cannot be bound to an effect that has not been produced yet, so `f(param)` -cannot become `const x0` until `e` resolves. `step` recurses into itself inside -the continuation it rebuilds, `foldStep` composes one step per item, and -`historyStep` runs `f` inside `e`'s continuation. Each writes that nesting down -**once**, in one line, so no caller ever writes it again. That is the point of the -combinator, not an exemption from the rule: without `historyStep` the rule would -be unfollowable the moment a later link needed an earlier link's value. Read a -nested `step` in a `fjs/effects` combinator as the rule being paid for, and one -anywhere else as the rule being broken. - -### 6.5 FunctionalScript module rules - -Authored FunctionalScript source is JavaScript with JSDoc. Relative -repository-owned dependencies follow these source rules: - -- `.f.mjs` is authored FunctionalScript implementation/proof source, and its - relative runtime imports target `.f.mjs`; -- `types.ts` is authored type-only TypeScript source and carries no runtime - implementation; -- `.f.mjs` — and later `.f.js` — consumes `types.ts` through JSDoc `@import`, - and TypeScript consumes it through `import type`, both always naming the real - `types.ts` file; -- a declaration-only module belongs in `types.ts` rather than acquiring an - artificial runtime representation; -- never add a runtime import/export or runtime value solely to represent a - TypeScript-only type declaration; -- compiler support does not gate the later `.f.mjs` -> `.f.js` rename; - FunctionalScript parser coverage and package support do. - -Avoid references to built-in or external Node modules such as `node:path` in -FunctionalScript source. No `try`/`catch` — see -[§3.5](#35-never-use-trycatch-test-throwing-with-the-throw-key). - -### 6.6 Formatting - -Don't vertically align code with padding spaces (e.g. extra spaces before `:` / -`=` to line up values across rows). It churns on every edit and makes -`git blame` noisy. Write `'actions/checkout': 'v5',` not -`'actions/checkout': 'v5',`. Vertical alignment is fine -in markdown, documentation, and comments. - -### 6.7 Rust - -Avoid `macro_rules!` in Rust code. Declarative macros hide types from -rust-analyzer, break grep and jump-to-definition, and encourage "invisible code" -that contradicts FunctionalScript's preference for explicit, locally-readable -values. When per-type trait boilerplate looks like a macro candidate (e.g. one -impl block per nominal newtype, byte-identical modulo names), prefer in this -order: - -1. a sealed helper trait carrying the variant choice with one-line per-type impls - and a single blanket `impl` deriving the boilerplate; -2. a `build.rs` code generator driven from a small source-of-truth table written - in plain Rust (or a FunctionalScript module if the same table drives other - artifacts too); -3. accept the hand-written duplication as the cost of readability. - -Reach for `macro_rules!` only when no other option is materially better for -readers. - ---- - -## 7. Issues (`todo/`) - -Issues are tracked in `todo/` directories, not on GitHub. See -[todo/README.md](./todo/README.md) for the full format and priority/status -conventions. - -GitHub issues are an **intake** channel, not a tracker: external contributors -cannot add `todo/` files, so they report there instead (see -[CONTRIBUTING.md](./CONTRIBUTING.md)). A maintainer turns each such report into a -`todo/` file — see the table below. - -| Situation | What to do | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Where to file** | Next to the code it describes. A bug scoped to `fjs/foo/bar/` goes in `fjs/foo/bar/todo/{slug-kebab}.md`. Cross-cutting or language-design issues go in the top-level `todo/`. | -| **How to file** | Create `todo/{slug-kebab}.md` using a short kebab-case slug. Follow the issue format in `todo/README.md`: title, priority, status, problem, proposal, tasks, related links. | -| **Reported on GitHub** | Create the `todo/` file for the report, linking the GitHub issue from its `Related` section. The `todo/` file is the tracked issue from then on; the GitHub issue stays open only as the reporter's thread and is closed when the fix ships. | -| **After fixing** | Delete the issue file immediately in the same PR. Before deleting, ensure design decisions are captured in the codebase (see the documentation table in [§4](#4-documentation)). | -| **Won't fix** | Document the reason in the relevant `README.md`, in a code comment, or in another issue — then delete the issue file. Do not leave a status-only tombstone. | -| **Blocked by a third party** | File under `todo/blocked/{slug-kebab}.md`. Every file there **must** include a **Trigger** section stating the precise condition that unblocks it. Do not put third-party-blocked items in regular `todo/` directories. | +## Contents -Reference issues with an explicit link, not GitHub's `#` prefix. `#NNN` is -reserved for GitHub PR/issue numbers. +1. [Workflow](#1-workflow) +2. [Environment and running tests](#2-environment-and-running-tests) +3. [FunctionalScript and TypeScript (`fjs/`)](#3-functionalscript-and-typescript-fjs) +4. [Rust (`nanvm-lib/`)](#4-rust-nanvm-lib) +5. [Pull requests and releases](#5-pull-requests-and-releases) --- -## 8. Pull requests - -### 8.1 Scope - -The PR should implement only one feature/improvement with minimal code changes. - -### 8.2 Before submitting - -Ensure all of the checks in [§2](#2-everyday-workflow) pass. - -### 8.3 CHANGELOG - -The changelog is the [./changelog/](./changelog/) directory: a directory per -released version (a single file per version through `0.44.0`) plus -`changelog/unreleased/` holding one file per unreleased PR — see -[changelog/README.md](./changelog/README.md) for the layout. - -To add a CHANGELOG entry, first open the PR to obtain its number, then create -`changelog/unreleased/.md` named by that number — recreating -`changelog/unreleased/` if a release just consumed it (Git does not track -empty directories). A PR never edits another PR's file, so two PRs can never -conflict. Write entries in the `Topic: short description` style, with no PR -number or link inside the file — the file name already carries the number, and -a renderer derives the link from it. A PR with several entries puts them all -in its one file, most important first. CHANGELOG entries are created after the -PR exists because the file is named by the PR number. +## 1. Workflow -Only add CHANGELOG entries for code changes — PRs that only touch `todo/`, -`AGENTS.md`, or other documentation files do not need one. +Find or file the issue in `todo/` first, next to the code it describes; for +anything non-trivial make sure it contains a concrete design before writing +code. Write the code plus its proof, run `npm run update` after changing source, +run the check set above, and delete the `todo/` issue file in the same PR that +fixes it. -- **Keep it short.** An entry is **at most a few lines** (about three wrapped - lines, ~250 characters) — what changed and, when it isn't obvious, why. It is a - release note for users of the package, not a design document. Rationale, - migration walkthroughs, measurements, and alternatives-considered belong in the - PR description, the relevant `README.md`, or JSDoc on the affected exports; the - entry's file name identifies the PR, so a reader can go there for the full - story. -- **No links.** The file name is the PR number, so an entry neither repeats it - nor links to the PR. Do not link to — or name in plain text — an issue or - `todo/` file either: issue files are deleted when the work is done, so those - references rot and mean nothing to a reader of the published package. -- **A file holds list items only.** No heading — the version or PR number is the - file name — and no Markdown beyond paragraphs, list items, inline code, and - bold, so the website can render entries with a small self-hosted parser. -- These rules govern **new** entries. Don't rewrite a released entry as a side - effect of an unrelated PR — a feature PR touches its own file and nothing - else. Entries written before this convention end with an inline - `[#NNN](url)` PR link (and the oldest have none); they are published history, - so leave them as they are. A deliberate cleanup pass over past releases is a - legitimate PR of its own (this convention arrived as one), and no released - text is lost when it happens: the full prior wording stays in the PR and in - git history. +Format, priorities, where each issue file belongs, and how GitHub-reported bugs +become `todo/` files: [todo/README.md](./todo/README.md). -### 8.4 Breaking changes and versioning +## 2. Environment and running tests -- Make breaking changes whenever they are the right design — don't preserve a - worse API (e.g. a stale re-export or a non-canonical export location) just to - avoid churn, and don't treat "it's already published" as a reason to keep a - shape ([§5.2](#52-the-api-is-the-most-important-part-of-quality)). The version - number is what lets consumers stay on the old API; a released version is - immutable, so nothing is taken away from anyone by improving the next one. - When a change breaks the public API, prefix its CHANGELOG entry with - `**BREAKING CHANGES:**` and update every importer in the same PR rather than - keeping a compatibility shim. -- **The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html), - and the CHANGELOG decides which number moves.** A `**BREAKING CHANGES:**` entry - anywhere in `changelog/unreleased/` means the release shipping it cannot be a - patch. The package is still pre-1.0, where the leading `0.` is pinned and the - *minor* position plays the role the major one plays after 1.0: +`npm ci` installs Node dependencies and `cargo fetch` the Rust ones. `npm test` +runs `tsc` plus the FunctionalScript suite; `fjs test` and its Deno, Bun, and +published-CLI equivalents run the same suite. To run only the tests under a +subtree, `cd` into it and run the runner from there. - | `changelog/unreleased/` contains | Pre-1.0 — `0.Y.Z` | 1.0 and later — `X.Y.Z` | - | ------------------------------------------- | ----------------- | ----------------------- | - | at least one `**BREAKING CHANGES:**` entry | `0.(Y+1).0` | `(X+1).0.0` | - | new features, nothing breaking | `0.Y.(Z+1)` | `X.(Y+1).0` | - | fixes only | `0.Y.(Z+1)` | `X.Y.(Z+1)` | +Required tool versions, every equivalent way to run the suite, and the +dependency-update procedure: [CONTRIBUTING.md](./CONTRIBUTING.md). - Pre-1.0 the leading `0.` costs one position, and the distinction it costs is - feature-vs-fix, not the break signal: `0.Y` moves **only** for a breaking - change, and everything else — new features included — is a patch. That is - deliberate. `^0.41.0` and `~0.41.0` both resolve to `>=0.41.0 <0.42.0` under - npm (Cargo's bare `0.41.0` and JSR/Deno agree), so while the package is pre-1.0 - the minor is the only upgrade boundary a resolver enforces. Reserving it for - breaking changes makes crossing it mean "something broke, read the entries" and - makes every patch release a safe upgrade that still delivers features — the - same contract the 1.0-and-later column gives, one position to the left. SemVer - §4 leaves `0.y.z` undefined ("Anything MAY change at any time"), so this is a - convention chosen inside the spec rather than a departure from it. +## 3. FunctionalScript and TypeScript (`fjs/`) - A bigger bump is a number, not a cost — it never argues for holding back a - breaking change, it only records that one happened. Releases through `0.41.0` - predate this convention and took a minor bump for feature-only releases too - (`0.35.0`, `0.33.0`); they are published, so leave their numbers alone. -- Releasing is its own commit: the version lives in `package.json` (`"version"`) - — `deno.json` holds tasks and formatting only. When it's bumped, rename - `changelog/unreleased/` to `changelog/X.Y.Z/`, keeping the entry files - exactly as they are. The next PR that adds an entry recreates - `changelog/unreleased/`. Releases through `0.44.0` are single - `changelog/X.Y.Z.md` files; leave them as they are. -- **After every update of the release PR from `main`, check that - `changelog/unreleased/` is empty.** A PR merged after the rename puts its - entry file back into `changelog/unreleased/`, and an update from `main` - carries it into the release branch — outside the renamed directory. Move any - such file into `changelog/X.Y.Z/` before merging the release, or its change - ships unrecorded in the changelog. Check again right before merging. -- **The repository has no Git tags and is not going to get any.** "Which - entries shipped in this release" is answered by `changelog/X.Y.Z/`, which - holds one file per PR that shipped in it; a tag would be a second copy of - that fact, kept in step by hand. +Every new `.f.mjs` module ships a co-located `proof.f.mjs` with **100% proof +coverage** — every export called, every line executed, every branch taken. +Values are immutable (no in-place mutation, no `.push`/`Map#set`/index +assignment), there is no `try`/`catch` and no regular expressions, and types are +written in JSDoc with a sibling `types.ts` for a type-level API. -### 8.5 Commit messages +Testing, documentation, and the full coding style: [fjs/AGENTS.md](./fjs/AGENTS.md). -`main` takes exactly one commit per PR: the squash merge, titled -` (#NNN)` with the PR description as its body. Both halves are -reviewed text that outlives the PR page, and a changelog generated from Git -history could read nothing else, so write the title and the description as the -commit message they become. Commits on the branch are discarded by the squash, -so their messages are working notes. +## 4. Rust (`nanvm-lib/`) -- **Title.** `: ` — `` is the module path - (`types/bit_vec`, `djs/tokenizer`) or an area (`ci`, `docs`, `changelog`, - `AGENTS.md`), the same topic the CHANGELOG entry starts with; the - description is imperative, lower-case after the colon, and has no trailing - period. Keep it within 72 characters **including** the ` (#NNN)` GitHub - appends, and never write a `(#NNN)` of your own. A release PR's title is the - bare version: `0.45.0`. -- **Description.** Free prose — motivation, design, measurements, alternatives - considered — then a `Changelog:` section, the last section before an optional - trailer block (`Co-Authored-By:`, generated-with lines, session links): +`cargo test`, `cargo clippy`, and `cargo fmt -- --check` all have to pass. Avoid +`macro_rules!` — declarative macros hide types from tooling and contradict this +repository's preference for explicit, locally-readable code. - ``` - +Commands and Rust coding style: [nanvm-lib/AGENTS.md](./nanvm-lib/AGENTS.md). - Changelog: - - `types/bit_vec`: `tryListToVec` reuses the shared balanced fold, at the - same cost as the accumulator it replaces - ``` +## 5. Pull requests and releases - The section holds exactly the list items of `changelog/unreleased/.md` — - same Markdown subset, same `**BREAKING CHANGES:**` prefix where it applies, - no PR link ([§8.3](#83-changelog)). A PR that needs no entry writes - `Changelog: none`. The section is **mandatory** either way, so a forgotten - entry is a visible omission rather than a silent one. +A PR implements only one feature or improvement, with minimal code changes, and +every check above passing. Its title and description become the squash commit on +`main`, so write them as one: a `: ` title and a +description ending in a mandatory `Changelog:` section. A PR that changes code +adds `changelog/unreleased/.md`, named by the real PR number once the PR +exists; a PR that only touches `todo/`, `AGENTS.md`, or other documentation +writes `Changelog: none` instead. Breaking changes are welcome when they improve +the API — prefix the entry with `**BREAKING CHANGES:**` and update every importer +in the same PR. - It duplicates the entry file on purpose: the file is what today's release - process reads, the section is what a generator reading Git history would - read. Neither is derived from the other, so keep them identical. -- **How it lands.** Squash and merge, always. The merge box offers the reviewed - title and description as the default message — don't edit it there, where - nobody reviews the result. A rebase merge would replay the branch's commits - with their working-note messages and no `(#NNN)`; a merge commit would bury - the PR in a two-parent graph. Nothing lands on `main` outside a PR. +Commit-message format and the PR checklist: [CONTRIBUTING.md](./CONTRIBUTING.md#opening-a-pull-request). +Changelog entry rules, breaking changes, and versioning: +[changelog/README.md](./changelog/README.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4c9f05aa..aa7dc78dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,10 +5,13 @@ language, its standard modules, and the `fjs` CLI) and `nanvm-lib/` (NaNVM, the native FunctionalScript VM, in Rust). **Coding style, testing rules, design principles, and pull request requirements -all live in [AGENTS.md](./AGENTS.md).** Read it before opening a pull request — -it applies to human and AI contributors alike. This file covers only getting a -working environment; it links to `AGENTS.md` rather than restating it, so the two -cannot drift apart. +start in [AGENTS.md](./AGENTS.md).** Read it before opening a pull request — it +applies to human and AI contributors alike. That file is a map: the +repository-wide design principles are in [DESIGN.md](./DESIGN.md), the +FunctionalScript and TypeScript rules in [fjs/AGENTS.md](./fjs/AGENTS.md), and +the Rust ones in [nanvm-lib/AGENTS.md](./nanvm-lib/AGENTS.md). This file covers +getting a working environment and opening a pull request; every document links +to the others rather than restating them, so they cannot drift apart. ## Issues @@ -18,7 +21,7 @@ start, and for the format to use when filing a new one. To **file** an issue yourself, add its `todo/` file in a pull request. Note that a pull request that **fixes** an issue does the opposite — it deletes that -issue's `todo/` file; see [AGENTS.md §2](./AGENTS.md#2-everyday-workflow). +issue's `todo/` file; see [AGENTS.md §1](./AGENTS.md#1-workflow). To report a bug, request a feature, or ask a question without opening a pull request — the normal case for an external contributor, who cannot add a `todo/` @@ -40,9 +43,12 @@ work is tracked from then on. You may also use the [Dockerfile](./docker/Dockerfile), which sets all of this up and is the easiest way to get a known-good environment. -Node 22 also supports `node --test` and `npm run cov`: external test -registration automatically uses an inline compatibility strategy below Node -`26.0.0`. +### Node test-runner compatibility + +External test registration automatically uses an inline compatibility strategy +below Node `26.0.0`, so `node --test` and `npm run cov` correctly handle +`throw`-tagged tests on Node 22. Node `26.0.0` and later use the native +`expectFailure` strategy and remain the fully supported native baseline. ### Installing dependencies @@ -61,9 +67,44 @@ cargo clippy cargo fmt -- --check ``` -`npm test` is one of several ways to run the FunctionalScript suite; the Deno, -Bun, and published-CLI equivalents are listed in -[AGENTS.md §1.4](./AGENTS.md#14-ways-to-run-the-functionalscript-test-suite). +#### Ways to run the FunctionalScript test suite + +Every row below runs the same suite; pick the first one that fits your +environment. + +| Command | Runtime | Needs internet | Notes | +| --------------------------------------- | -------- | -------------- | ---------------------------------------- | +| `npm test` | Node 22+ | no | `tsc` + the repo's runner. | +| `npm start test` | Node 22+ | no | The repo's runner, no type-check step. | +| `node --test` | Node 22+ | no | Node's native test runner. | +| `npm run cov` | Node 22+ | no | `node --test` plus coverage. | +| `deno task fjs test` | Deno | no | The repo's runner under Deno. | +| `deno task test` / `deno task cov` | Deno | no | Deno's native test runner / coverage. | +| `bun fjs/module.mjs test` | Bun | no | The repo's runner under Bun. | +| `bun test` | Bun | no | Bun's native test runner. | +| `fjs test` | Node 22+ | to install | After `npm install -g functionalscript`. | +| `npx functionalscript test` | Node 22+ | yes | No install step. | +| `deno run -A npm:functionalscript test` | Deno | yes | No install step. | +| `bunx functionalscript test` | Bun | yes | No install step. | + +The last four rows run a **published** FunctionalScript rather than this working +tree's version. `npx`, `deno run`, and `bunx` resolve the latest release each +time; `fjs` runs whatever you installed globally, which goes stale as new +versions ship — re-run `npm install -g functionalscript` to update it. + +Deno needs explicit permissions: `-A` is the short form, or pass the same set as +the `fjs` task in [deno.json](./deno.json) (`--allow-read --allow-write +--allow-env --allow-net --allow-sys`). Deno also holds back very recently +published versions; add `--minimum-dependency-age=0` to force the newest. + +CI exercises these same combinations — see the `node22`, `node24`, `node26`, +`deno`, and `bun` jobs in +[.github/workflows/ci.yml](./.github/workflows/ci.yml) for the exact commands +and pinned runtime versions. + +To run only the tests under a subtree, `cd` into that directory and run the +runner from there (e.g. `cd fjs/base64 && fjs test`). Module discovery starts at +the current working directory, and results are reported per test. To validate the packed npm package itself against clean Node, Deno, and Bun consumers — for example after changing `prepack`, `files`, or anything that @@ -71,7 +112,7 @@ affects emitted declarations — follow [`fjs/ci/packed-consumer-validation.md`](./fjs/ci/packed-consumer-validation.md). New `.f.mjs` modules need a co-located proof with 100% proof coverage — see -[AGENTS.md §3](./AGENTS.md#3-testing-and-proof-coverage). Authored +[fjs/AGENTS.md §1](./fjs/AGENTS.md#1-testing-and-proof-coverage). Authored FunctionalScript is JavaScript with JSDoc: a `module.f.mjs` is accompanied by a `proof.f.mjs`, and a separately useful type-level API may live in a sibling `types.ts`. Current FunctionalScript compiler support is not required for either @@ -117,21 +158,58 @@ For tool details and package-consumer setup for Claude and Codex, see ## Opening a pull request -The full workflow is in [AGENTS.md §2](./AGENTS.md#2-everyday-workflow) and -[AGENTS.md §8](./AGENTS.md#8-pull-requests). In short: one feature or improvement -per pull request, every check above passing, the `todo/` issue deleted in the -same pull request, and — for code changes — a changelog entry added as -`changelog/unreleased/.md`, named by the real pull request number once the -pull request exists (see [changelog/README.md](./changelog/README.md)). - -The pull request lands on `main` as a single squash commit titled -` (#NNN)` with the pull request description as its body, so -both are written as that commit message -([AGENTS.md §8.5](./AGENTS.md#85-commit-messages)): a -`: ` title within 72 characters including the -` (#NNN)` GitHub appends, and a description ending in a `Changelog:` section -that repeats the changelog entry — or `Changelog: none` when the change needs -no entry. +A pull request implements only one feature or improvement, with minimal code +changes. Before submitting, ensure every check above passes, delete the `todo/` +issue file in the same pull request, and — for code changes — add a changelog +entry as `changelog/unreleased/.md`, named by the real pull request number +once the pull request exists (see +[changelog/README.md](./changelog/README.md)). The everyday workflow around +this is [AGENTS.md §1](./AGENTS.md#1-workflow). + +### Commit messages + +`main` takes exactly one commit per pull request: the squash merge, titled +` (#NNN)` with the pull request description as its body. Both halves +are reviewed text that outlives the pull request page, and a changelog generated +from Git history could read nothing else, so write the title and the description +as the commit message they become. Commits on the branch are discarded by the +squash, so their messages are working notes. + +- **Title.** `: ` — `` is the module path + (`types/bit_vec`, `djs/tokenizer`) or an area (`ci`, `docs`, `changelog`, + `AGENTS.md`), the same topic the CHANGELOG entry starts with; the + description is imperative, lower-case after the colon, and has no trailing + period. Keep it within 72 characters **including** the ` (#NNN)` GitHub + appends, and never write a `(#NNN)` of your own. A release pull request's + title is the bare version: `0.45.0`. +- **Description.** Free prose — motivation, design, measurements, alternatives + considered — then a `Changelog:` section, the last section before an optional + trailer block (`Co-Authored-By:`, generated-with lines, session links): + + ``` + + + Changelog: + - `types/bit_vec`: `tryListToVec` reuses the shared balanced fold, at the + same cost as the accumulator it replaces + ``` + + The section holds exactly the list items of `changelog/unreleased/.md` — + same Markdown subset, same `**BREAKING CHANGES:**` prefix where it applies, + no PR link ([changelog/README.md](./changelog/README.md#entries)). A pull + request that needs no entry writes `Changelog: none`. The section is + **mandatory** either way, so a forgotten entry is a visible omission rather + than a silent one. + + It duplicates the entry file on purpose: the file is what today's release + process reads, the section is what a generator reading Git history would + read. Neither is derived from the other, so keep them identical. +- **How it lands.** Squash and merge, always. The merge box offers the reviewed + title and description as the default message — don't edit it there, where + nobody reviews the result. A rebase merge would replay the branch's commits + with their working-note messages and no `(#NNN)`; a merge commit would bury + the pull request in a two-parent graph. Nothing lands on `main` outside a + pull request. ## OpenAI Codex environment diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..65f0f8f5f --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,198 @@ +# Design principles + +These principles are repository-wide: they govern both code bases — `fjs/` +(FunctionalScript / TypeScript) and `nanvm-lib/` (Rust). The first two are +restated in brief at the top of [AGENTS.md](./AGENTS.md); everything here is +their full text. + +## Contents + +1. [Simplicity first](#1-simplicity-first) +2. [The API is the most important part of quality](#2-the-api-is-the-most-important-part-of-quality) +3. [Design before implementation](#3-design-before-implementation) +4. [Reuse, DRY, and separation of concerns](#4-reuse-dry-and-separation-of-concerns) +5. [Declarative over imperative](#5-declarative-over-imperative) +6. [Never precompute a size to predict whether something fits](#6-never-precompute-a-size-to-predict-whether-something-fits) +7. [CLI parameters over environment variables](#7-cli-parameters-over-environment-variables) +8. [Embedded DSLs should reuse host-language syntax](#8-embedded-dsls-should-reuse-host-language-syntax) + +--- + +## 1. Simplicity first + +**Always prefer simplicity and quality over optimization.** Never optimize +prematurely, and especially never at the cost of simplicity. A simple, correct, +generic solution comes first; optimization work starts only after confirming it +is actually needed (a measured problem or a real limit being hit, not a hunch), +and even then it is a **separate task**: file it as its own `todo/` issue instead +of folding it into the current change. + +When that task is taken up, still solve the problem in a generic way — improve +the algorithm, the data structure, or the API — instead of hacking special cases +into an otherwise general design (byte-prefix sniffing instead of real parsing, +key-order assumptions, hardcoded fast paths). A documented implementation limit +that a later generic improvement can lift (e.g. a size bound on a buffering +parser) is an acceptable interim answer; a semantic assumption baked into a +format or contract for speed is not. + +## 2. The API is the most important part of quality + +**Quality is the main priority, and the API is the most important part of it.** +A clean, readable, simple API for the modules that consume it is worth more than +any existing API's shape. **If the new version can have a better, simpler API, +change it — never hesitate.** An API kept only because something already calls it +is how a codebase ends up with a heap of legacy nobody is allowed to modify, and +every later design is then bent around it. Never cut corners, hack, or bend a +caller's input/output to fit an existing API's shape just to avoid touching that +API. + +When the existing design is the obstacle, **fix the design**: rewrite the API and +make a breaking change, updating every importer in the same PR (see +[changelog/README.md](./changelog/README.md#breaking-changes-and-versioning)). +Every consumer inside this repository is visible and updatable, so a hard cutover +is nearly always available — take it. Adjusting a call site to work around a poor +API, instead of improving the API, is the wrong trade-off here. + +Keeping the old API alongside the new one is a **last resort**, not the +convenient middle path: two shapes for one concept doubles what a reader has to +understand and, in practice, the old one never leaves. If a rewrite is genuinely +too large for one PR, split it by **scope** — module by module, each step its own +complete breaking change — rather than by **time**. If a transitional API is +still unavoidable, file a `todo/` issue for removing the old one as part of the +same change; the work isn't done until that issue is deleted. + +**If you see a way to improve an API — or a new API that would make consuming +modules simpler and more readable — propose it as soon as you notice it.** Don't +defer or silently work around it. File a `todo/` issue with a concrete design +(see [todo/README.md](./todo/README.md)) so it can be reviewed promptly; if the +improvement is in scope for what you're already doing, raise it before building +on top of the weaker design. + +## 3. Design before implementation + +- Before implementing a non-trivial feature, ensure the corresponding issue + document in `todo/` contains a concrete design. If the issue exists but the + design is absent, vague, or contradicts the codebase or runtime behavior, + update the issue first and wait for review — do not write code against an + incomplete or incorrect design. +- When a discrepancy is found between an issue's design and reality (a missing + API, a wrong environment variable, an incompatible type), correct the design + document and surface the problem rather than silently working around it. +- Before relying on an undocumented or assumed runtime behavior (environment + variable names, API shape, framework detection), verify it with a small test or + source check rather than assuming. + +## 4. Reuse, DRY, and separation of concerns + +- **Reuse code.** +- **Don't Repeat Yourself (DRY)** — a core principle of FunctionalScript, not + just a stylistic preference. When two or more modules share an algorithm and + differ only in constants, alphabets, or small helpers, extract a parameterized + factory into a shared module rather than copy-pasting. Combined with the + previous point: only extract once the second real consumer exists. +- **Separation of concerns** — move logic to its natural module even with a + single consumer when the logic is conceptually distinct (e.g. path manipulation + belongs in `fjs/path`, not inline in a loader). First search for an appropriate + existing module; create a new one only if no good fit exists. This is different + from DRY extraction: it is always appropriate. +- **Avoid side effects and mutability.** + +### Exception to DRY: performance measurement + +Time measurement must capture immediately after an operation completes to avoid +measuring the wrapper code itself. This naturally leads to duplication when both +success and error paths must measure. Readability is more important than +eliminating the duplication — keep each measurement explicit and close to its +operation: + +```ts +sandbox: async (f: () => T) => { + let result: Result + let after: number + const before = performance.now() + try { + const value = await f() + after = performance.now() + result = ok(value) + } catch (e) { + after = performance.now() + result = error(e) + } + return { result, duration: after - before } +} +``` + +Why this pattern is good: + +- The two `after = performance.now()` calls are necessary on the critical path — + extracting them into a helper would measure the helper function's overhead + instead of just the operation. +- TypeScript tracks uninitialized values: declaring `let after: number` without + initialization lets the type checker verify that `after` is assigned in all + code paths before the final `return` statement. +- We still avoid duplication of non-critical computations: the return value of + the function (`{ result, duration: after - before }`) is formed once, not + duplicated. Only the timing capture (which must be immediate) appears twice. + +## 5. Declarative over imperative + +**Prefer declarative style over imperative.** When defining tools, handlers, +dispatchers, or similar abstractions, favor data-driven definitions (metadata + +schema + handler together in an array or registry) over imperative switch +statements or hardcoded conditionals. Declarative patterns are easier to extend, +test, and reason about. For example: define tools as an array of +self-descriptive objects (name, description, schema, handler) and dispatch +generically over them, rather than hardcoding a switch on tool name. + +## 6. Never precompute a size to predict whether something fits + +**Never precompute or estimate an encoding/decoding size to predict whether it +will fit a limit.** Attempt the real decode/encode and branch on its result +instead. Size estimates (string-length lower bounds, base64's 3/4 ratio, +JSON-escaping multipliers, …) are easy to get subtly wrong — and a +wrong-in-the-unsafe-direction estimate reintroduces the exact crash the check was +meant to prevent — while the real operation is always exactly right. + +Express the fallible operation as a `try*` function returning `Nullable` (see +`tryUtf8`, `tryListToVec`, `tryU8ListToVec`, `base64Decode` in `fjs/text`, +`fjs/types/bit_vec`, `fjs/base64`), add a new `try*` variant if one doesn't exist +yet for the operation you need (including effect primitives like `write`), and +have the caller check the `null` result rather than a precomputed bound. + +## 7. CLI parameters over environment variables + +CLI parameters are preferred over environment variables when adding new +features. + +## 8. Embedded DSLs should reuse host-language syntax + +**An embedded DSL should reuse JavaScript / FunctionalScript values and syntax +whenever their existing meaning is exactly the meaning the DSL needs.** Prefer +ordinary numbers, strings, arrays, and objects over wrapping the same information +in tagged syntax. For example, prefer `3.14`, `'abc'`, `[1, 2]`, and `{ x: 1 }` +over representations such as `['number', 3.14]` or an object/array tag whose only +purpose is to say what the host value already says. + +Introduce a constructor, function, tag, or other DSL-specific form only for a +concept the host language cannot express directly and unambiguously. RTTI follows +this pattern: constants can describe themselves, while constructions such as +`array(number)` need DSL syntax because an array *value* and the type "array of +numbers" are different concepts. The proposed NaNVM operator-test data eDSL applies the same principle: ordinary +operands and expected results should be ordinary JavaScript values, while +references, function values, and expected throws need special forms. + +Do not expose a tagged-union AST as the authoring API merely because it is +convenient for the implementation. The ergonomic eDSL and its normalized +machine-oriented representation may be different layers: a parser/compiler may +normalize an author-friendly value into explicit tagged nodes for pattern +matching, serialization, hashing, or code generation. Prefer the simplest representation that preserves the required semantics. Avoid +redundant DSL syntax: less representational noise benefits people, AI systems, +deterministic computation, hashing, serialization, storage, and code generation +alike. Use a more explicit normalized representation only when that extra +structure provides actual semantic or processing value. + +Apply this principle to new eDSLs and when improving existing ones, including the +future FunctionalScript function AST. That AST should reuse FunctionalScript's +own literals, arrays, objects, and other language constructions wherever their +meaning coincides with the syntax being represented, and introduce explicit AST +nodes only where the host-language value would be ambiguous or insufficient. diff --git a/changelog/README.md b/changelog/README.md index a0c3e9599..11487af62 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -34,29 +34,100 @@ Entries are therefore ordered by pull-request number, not by merge order — a pull request opened earlier can merge after one opened later. The deviation is accepted: pull-request order is deterministic and conflict-free. -An entry file holds only Markdown list items — the version or pull-request -number is the file name, never a heading inside the file. Entries stay in a -small Markdown subset: paragraphs, list items, inline code, bold, and links. -That subset is a convention rather than an accident, so the changelog can be -rendered on the website by a self-hosted parser. A `.md` file that is -empty retrofits a released section that recorded no entries. - -## Versioning - -While the package is pre-1.0, the minor position carries the meaning the major -one will carry after 1.0: `0.Y` is bumped **only** by a release containing -`**BREAKING CHANGES:**`, and every other release — new features included — is a -patch bump. So `0.Y` is the API-compatibility boundary, which is also the -boundary `^0.Y.Z` and `~0.Y.Z` ranges already enforce: a patch upgrade is always -safe, and crossing `0.Y` always means reading the entries of the versions -crossed. Releases through `0.41.0` predate this convention and used a minor bump -for feature-only releases as well. - ## Entries -New entries are at most a few lines and contain no links: the file name is the -pull-request number, so repeating it — or linking it — inside the file is -redundant, and a renderer derives the PR link from the name. Entries written -before the directory-per-version layout end with an inline PR link (and a few -of the oldest have none) — they are kept as history, not rewritten. The full -rules are in [AGENTS.md §8.3](../AGENTS.md#83-changelog). +To add an entry, first open the pull request to obtain its number, then create +`changelog/unreleased/.md` named by that number — recreating +`changelog/unreleased/` if a release just consumed it. Entries are created after +the pull request exists precisely because the file is named by its number. Write +them in the `Topic: short description` style, with no pull-request number or link +inside the file — the file name already carries the number, and a renderer +derives the link from it. A pull request with several entries puts them all in +its one file, most important first. + +Only add entries for code changes — pull requests that only touch `todo/`, +`AGENTS.md`, or other documentation files do not need one, and say +`Changelog: none` in the description instead. + +- **Keep it short.** An entry is **at most a few lines** (about three wrapped + lines, ~250 characters) — what changed and, when it isn't obvious, why. It is a + release note for users of the package, not a design document. Rationale, + migration walkthroughs, measurements, and alternatives-considered belong in the + pull request description, the relevant `README.md`, or JSDoc on the affected + exports; the entry's file name identifies the pull request, so a reader can go + there for the full story. +- **No links.** The file name is the pull-request number, so an entry neither + repeats it nor links to the pull request. Do not link to — or name in plain + text — an issue or `todo/` file either: issue files are deleted when the work + is done, so those references rot and mean nothing to a reader of the published + package. +- **A file holds list items only.** No heading — the version or pull-request + number is the file name — and no Markdown beyond paragraphs, list items, + inline code, and bold, so the website can render entries with a small + self-hosted parser. That subset is a convention rather than an accident. A + `.md` file that is empty retrofits a released section that recorded + no entries. +- These rules govern **new** entries. Don't rewrite a released entry as a side + effect of an unrelated pull request — a feature pull request touches its own + file and nothing else. Entries written before this convention end with an + inline `[#NNN](url)` pull-request link (and the oldest have none); they are + published history, so leave them as they are. A deliberate cleanup pass over + past releases is a legitimate pull request of its own (this convention arrived + as one), and no released text is lost when it happens: the full prior wording + stays in the pull request and in git history. + +## Breaking changes and versioning + +- Make breaking changes whenever they are the right design — don't preserve a + worse API (e.g. a stale re-export or a non-canonical export location) just to + avoid churn, and don't treat "it's already published" as a reason to keep a + shape (see [DESIGN.md §2](../DESIGN.md#2-the-api-is-the-most-important-part-of-quality)). + The version number is what lets consumers stay on the old API; a released + version is immutable, so nothing is taken away from anyone by improving the + next one. When a change breaks the public API, prefix its CHANGELOG entry with + `**BREAKING CHANGES:**` and update every importer in the same pull request + rather than keeping a compatibility shim. +- **The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html), + and the CHANGELOG decides which number moves.** A `**BREAKING CHANGES:**` entry + anywhere in `changelog/unreleased/` means the release shipping it cannot be a + patch. The package is still pre-1.0, where the leading `0.` is pinned and the + *minor* position plays the role the major one plays after 1.0: + + | `changelog/unreleased/` contains | Pre-1.0 — `0.Y.Z` | 1.0 and later — `X.Y.Z` | + | ------------------------------------------- | ----------------- | ----------------------- | + | at least one `**BREAKING CHANGES:**` entry | `0.(Y+1).0` | `(X+1).0.0` | + | new features, nothing breaking | `0.Y.(Z+1)` | `X.(Y+1).0` | + | fixes only | `0.Y.(Z+1)` | `X.Y.(Z+1)` | + + Pre-1.0 the leading `0.` costs one position, and the distinction it costs is + feature-vs-fix, not the break signal: `0.Y` moves **only** for a breaking + change, and everything else — new features included — is a patch. That is + deliberate. `^0.41.0` and `~0.41.0` both resolve to `>=0.41.0 <0.42.0` under + npm (Cargo's bare `0.41.0` and JSR/Deno agree), so while the package is pre-1.0 + the minor is the only upgrade boundary a resolver enforces. Reserving it for + breaking changes makes crossing it mean "something broke, read the entries" and + makes every patch release a safe upgrade that still delivers features — the + same contract the 1.0-and-later column gives, one position to the left. SemVer + §4 leaves `0.y.z` undefined ("Anything MAY change at any time"), so this is a + convention chosen inside the spec rather than a departure from it. + + A bigger bump is a number, not a cost — it never argues for holding back a + breaking change, it only records that one happened. Releases through `0.41.0` + predate this convention and took a minor bump for feature-only releases too + (`0.35.0`, `0.33.0`); they are published, so leave their numbers alone. +- Releasing is its own commit: the version lives in `package.json` (`"version"`) + — `deno.json` holds tasks and formatting only. When it's bumped, rename + `changelog/unreleased/` to `changelog/X.Y.Z/`, keeping the entry files + exactly as they are. The next pull request that adds an entry recreates + `changelog/unreleased/`. Releases through `0.44.0` are single + `changelog/X.Y.Z.md` files; leave them as they are. +- **After every update of the release pull request from `main`, check that + `changelog/unreleased/` is empty.** A pull request merged after the rename puts + its entry file back into `changelog/unreleased/`, and an update from `main` + carries it into the release branch — outside the renamed directory. Move any + such file into `changelog/X.Y.Z/` before merging the release, or its change + ships unrecorded in the changelog. Check again right before merging. +- **The repository has no Git tags and is not going to get any.** "Which + entries shipped in this release" is answered by `changelog/X.Y.Z/`, which + holds one file per pull request that shipped in it; a tag would be a second + copy of that fact, kept in step by hand. diff --git a/docker/README.md b/docker/README.md index c82426b05..fd1aa1ffe 100644 --- a/docker/README.md +++ b/docker/README.md @@ -27,7 +27,7 @@ docker run --rm -it --network none functionalscript - `deno task test` - `bun test` -See [AGENTS.md §1.4](../AGENTS.md#14-ways-to-run-the-functionalscript-test-suite) +See [CONTRIBUTING.md](../CONTRIBUTING.md#ways-to-run-the-functionalscript-test-suite) for the full list of equivalent ways to run the FunctionalScript test suite. ## Codex Setup diff --git a/fjs/AGENTS.md b/fjs/AGENTS.md new file mode 100644 index 000000000..52950d65d --- /dev/null +++ b/fjs/AGENTS.md @@ -0,0 +1,856 @@ +# FunctionalScript and TypeScript (`fjs/`) + +Rules for everything under `fjs/` — authored FunctionalScript (`.f.mjs`) and the +type-only TypeScript (`types.ts`) beside it. Repository-wide rules live in the +root [AGENTS.md](../AGENTS.md), and the design principles both code bases follow +live in [DESIGN.md](../DESIGN.md). + +## Contents + +1. [Testing and proof coverage](#1-testing-and-proof-coverage) +2. [Documentation](#2-documentation) +3. [Coding style](#3-coding-style) + +--- + +## 1. Testing and proof coverage + +### 1.1 Commands + +- `npx tsc` — type-check using the repository's version of TypeScript. +- `fjs test` (or any equivalent from + [CONTRIBUTING.md](../CONTRIBUTING.md#ways-to-run-the-functionalscript-test-suite)) + — test FunctionalScript (`.f.mjs`) files. + +### 1.2 Proof coverage is mandatory + +New FunctionalScript modules and functions must have **100% proof coverage** +across every dimension: every exported function called, every line executed, and +every branch (both sides of each conditional) taken. This applies to authored +FunctionalScript source, `.f.mjs` +([`fjs/fsc/README.md`](./fsc/README.md) defines the extensions). A new +implementation module ships with a co-located proof (its `proof` export) that +exercises all of its exports along all code paths — partial coverage of new code +is not acceptable. If a line or branch genuinely cannot be reached, restructure +the code so it isn't there rather than leaving it uncovered. + +An implementation is `module.f.mjs` and its proof is `proof.f.mjs`. Stage 1 of +the TypeScript-to-JavaScript migration is complete: no authored implementation or +proof `.f.ts` remains, so write both files as JavaScript with JSDoc. Authored +`types.ts` companions may remain permanently and hold the type-level API. + +Proof discovery and coverage follow the same extension: `shouldLoad` in +[`fjs/dev/module.f.mjs`](./dev/module.f.mjs) matches authored +FunctionalScript source, and both `npm run cov` and `deno task cov` include +`module.f.mjs`. Ordinary (non-FunctionalScript) `.mjs` files stay opt-in through +the `proof.mjs` filename convention. + +A `proof.f.mjs` is authored `.f.mjs` like any other. Its relative **runtime** +imports must target `.f.mjs` modules. Type-only APIs may live in an authored +`types.ts` companion and are referenced directly through that real source path. +Its leading module JSDoc block may include, for example: + +```js +/** + * ... + * + * @module + * + * @import { Phantom } from '../phantom/types.ts' + */ +``` + +JSDoc `@import` introduces no runtime dependency; a `types.ts` file naming the +same path from TypeScript uses `import type` instead. A type that several modules +need independently of one implementation belongs in `types.ts`, not in a JSDoc +typedef that consumers would have to reach into the implementation for. Never add +a runtime value for a TypeScript-only declaration such as `declare const`. +Compiler support remains independent of this JavaScript/JSDoc rule. See +[`fjs/fsc/README.md`](./fsc/README.md) for the extension contract and module +policy. + +### 1.3 Use `assert` / `assertEq`, never a hand-written `if`/`throw` + +Assert results in `proof` code with `assert`/`assertEq` from +[`fjs/asserts/module.f.mjs`](./asserts/module.f.mjs), not a hand-written +`if (cond) { throw ... }`. + +A local `if`/`throw` in a test is itself a new branch for the coverage tool to +track, and its failure side is normally never exercised (the test is expected to +pass), so it lands as a permanently-uncovered branch in the very module meant to +close coverage gaps. `assert`/`assertEq` push that branch into a shared helper +whose own branches are already fully covered elsewhere, so the call site adds no +new uncovered branch. + +### 1.4 Assert type-level facts with `Assert>` + +To prove that a type resolves to what you claim, write +`type _Name = Assert>` — `Assert` from +`fjs/asserts/types.ts`, `Equal` from `fjs/types/ts/types.ts`. A wrong +claim is then a compile error (TS2344, "Type 'false' does not satisfy the +constraint 'true'"), and the check costs nothing at runtime. + +Do **not** state the claim as `true as _Predicate`, where `_Predicate` is a +conditional type resolving to `true` or `false`. That proves nothing: +TypeScript compares an assertion against the *widened* type of its operand, so +`true as false` — and `true as never` — are both legal, and the assertion +compiles no matter what the predicate resolved to. Such an entry in a `proof` +object is doubly inert: the runner only invokes functions, so a boolean leaf is +never counted as a test either. + +### 1.5 Never use `try`/`catch`; test throwing with the `throw` key + +Never use `try`/`catch` in `.f.mjs` files — FunctionalScript itself has no +`try`/`catch` and isn't planning to add it soon. To test that a call throws, +nest the test function under a `throw` property key instead of wrapping it in +`try`/`catch` (see `fjs/asserts/proof.f.mjs`). + +The test runner (`fjs/emergent_testing/module.f.mjs`) treats `throw` as a +structural marker: any function reachable under a `throw` key gets +`throws: true`, and the runner inverts the sandboxed result so a thrown error +counts as a pass — with no manual `caught`/`threw` flag or `assert` needed. + +Treat `throw` in FunctionalScript as a panic (like Rust's `panic!`, Go's +`panic`, or Java/C#'s unchecked `RuntimeException`), not as a language-level +`Result`/checked-exception value: nothing in FunctionalScript can catch it, so a +thrown payload is never pattern-matched or branched on by other FunctionalScript +code, and a correctly working program should never throw at all. Recoverable +failure belongs in `Result` (`fjs/types/result`), which callers actually +destructure and is worth asserting on precisely; a `throw`'s payload is read +only by a human or external tooling after something has already gone wrong, so +don't over-invest proof effort in checking its exact value — whether it threw is +normally the part of the contract that matters. + +--- + +## 2. Documentation + +Use JSDoc for module documentation in both JavaScript and TypeScript source. +The `@module` tag belongs only to a package's entry-point file — `module.f.mjs` / +`module.mjs` — not to `proof.f.mjs`, `types.ts`, or any other file. A `module.*` +file starts with one module JSDoc block carrying `@module`, followed by one blank +line before the first source-level import or declaration. A `proof.*` or other +non-`module.*` file has no `@module` tag and no required leading documentation +block; one is still needed if the file has `@import` tags to hold, per below. + +Group all module-level `@import` tags into one leading JSDoc comment block — the +same block as `@module` in a `module.*` file, or a standalone block at the top of +the file otherwise — then put one blank line before runtime imports. Do not +scatter `@import` tags as separate comments between or after individual `import` +statements. External or built-in runtime imports come first, followed by +repository-owned relative `.mjs` runtime imports, with one blank line between the +groups: + +```js +/** + * <...Module documentation...> + * + * @module + * + * @import ... + * @import ... + */ + +import ... from 'node:...' +import ... from 'package' + +import ... from '...mjs' +import ... from '...mjs' +``` + +A non-`module.*` file (e.g. `proof.f.mjs`) with `@import` tags but no `@module` +uses the same grouping without the tag: + +```js +/** + * @import ... + * @import ... + */ + +import ... from 'node:...' +import ... from 'package' + +import ... from '...mjs' +import ... from '...mjs' +``` + +Authored TypeScript you write is `types.ts`. Its imports are all type-only, so it +needs no grouping: `import type` names the same real source paths, whether the +type comes from another `types.ts` or from a `.f.mjs` module. + +```ts +import type ... from '../other/types.ts' +import type ... from './module.f.mjs' +``` + +There are no exceptions left: `types.ts` is the only authored TypeScript in +the repository. The former exception — the `fjs/emergent_testing/scenarios` +fixtures and the `all.test.ts` entry, whose `.ts` extension proved that Node, +Bun and Deno execute a TypeScript proof natively — was retired in +[#1520](https://github.com/functionalscript/functionalscript/pull/1520): the +scenario suite never ran in CI and is deleted, with recreation documented in +[`fjs/emergent_testing/scenarios.md`](./emergent_testing/scenarios.md), +and the test entry is authored `all.test.mjs`. + +The runtime-import grouping applies to repository-owned relative imports, not to +external or built-in modules: a FunctionalScript module may depend at runtime on +external modules and on repository `.mjs`, and there is no relative runtime `.ts` +import group at all. The blank line after the leading JSDoc block is required +in every file that has one — `module.*` (even with no `@import` tags), +`types.ts`, and `proof.*` alike; it keeps the header detached from the first +import/declaration and preserves it through declaration emit. `types.ts` is +the most easily missed case, since its emitted `types.d.ts` is the published +documentation for the whole type-level API. + +Where each kind of documentation belongs: + +| Content | Home | +| ------------------------------------------------ | ----------------------------------------- | +| API shape and invariants | JSDoc on `module.f.*` exports or `types.ts` | +| Architectural choices, *why this / why not that* | the relevant `README.md` | +| What changed in a release | `changelog/` (short, see [changelog/README.md](../changelog/README.md#entries)) | +| Rationale, measurements, alternatives considered | the PR description | + +--- + +## 3. Coding style + +### 3.1 Immutability and purity + +- Don't mutate arrays, sets, maps, or objects in place. Avoid `.push`, `.pop`, + `.shift`, `.unshift`, `.splice`, `.sort`, `.reverse`, `Set#add`, `Set#delete`, + `Map#set`, `Map#delete`, and index/property assignment on accumulators. Build + new values with `.map`, `.filter`, `.flatMap`, spread, `new Set([...prev, x])`, + `new Map([...prev, [k, v]])`, and `Object.fromEntries(entries.map(...))`. +- Use `let` variables only within the function body where they are declared. + +#### No regular expressions + +Do not use regular expressions. Express lexical checks and transformations with +ordinary typed functions so their structure, supported characters, and edge +cases remain explicit and independently testable. + +### 3.2 Types + +#### JavaScript/JSDoc type declarations + +Authored `.mjs` / `.f.mjs` files must remain valid JavaScript. Put named and +generic static types in JSDoc rather than TypeScript syntax, and keep public +assignability and declaration-emission behavior intact when a type's spelling +changes. A separately useful type-level API may live in an authored sibling +`types.ts`; that file remains TypeScript type source and holds no runtime +implementation. + +Name implementation-only JSDoc typedefs with a leading `_` +(`/** @typedef {number} _Type */`). Declaration emit cannot strip them yet, so +the underscore — not the emitted `.d.ts` — is what marks a name private, +and renaming or removing a `_`-prefixed alias is not by itself a breaking +change. The public contract still governs transitive effects. See +[Private JSDoc typedefs](./fsc/README.md#private-jsdoc-typedefs) for the +full rule and examples. + +Use `@typedef` for a named type and `@template` for its type parameters. A +constraint goes in braces before the parameter name: + +```js +/** + * @template {Operation} O + * @template T + * @typedef {(_: Pr[1]) => Effect} Cont + */ +``` + +TypeScript 7 also supports variance modifiers on JSDoc type-alias parameters. +Translate TypeScript `in` / `out` directly on `@template` instead of dropping +the variance annotation. For example: + +```ts +export type Cont = + (_: Pr[1]) => Effect +``` + +becomes: + +```js +/** + * @template {Operation} out O + * @template T + * @typedef {(_: Pr[1]) => Effect} Cont + */ +``` + +The supported forms are `@template out T`, `@template in T`, and constrained +forms such as `@template {Operation} out O`. Variance modifiers belong to type +parameters of a JSDoc type alias (`@typedef`); do not put `in` / `out` on an +ordinary function's `@template`, where TypeScript rejects them. + +When JavaScript needs a type from an authored `types.ts`, put JSDoc `@import` +with that real source path in the leading module JSDoc block; do not create a +separate `@import` comment. For example: + +```js +/** + * ... + * + * @module + * + * @import { Types } from './types.ts' + */ +``` + +Another `types.ts` referring to the same file uses `import type` with that same +path: + +```ts +import type { Types } from './types.ts' +``` + +Both forms are type-only and introduce no runtime import. The `types.ts` file +itself exists and is checked as ordinary TypeScript source, so this convention +does not rely on `.d.ts` substitution and works with Deno's source resolver. + +The fully erased forms are the only permitted ones — for package consumers as +well as repository code. The published package ships `types.d.ts` but no +`types.js` runtime module, and under `verbatimModuleSyntax` only `import type +{ X }` (and JSDoc `@import`) erase the whole statement: the inline form +`import { type X } from '…/types.js'` compiles to a retained `import {}`, as do +`import * as` and bare side-effect imports, and fails at runtime with +`ERR_MODULE_NOT_FOUND`. + +A declaration-only module belongs in `types.ts` rather than acquiring an +artificial JavaScript runtime representation. See [§2](#2-documentation) +for the complete module-header and import-order convention. + +Decide where a type lives by who needs it: one that must survive independently of +a single implementation goes in `types.ts`, while one that is naturally +implementation-local and expressible in JSDoc stays beside the code it describes. +Never invent a runtime import, export, `Symbol()`, or other value solely to +represent a TypeScript-only declaration such as `declare const`. + +When a public type is written in JSDoc, verify both normal type checking and the +emitted `.d.ts` / `.d.mts` declarations. The JSDoc spelling may differ from the +TypeScript one, but the public type contract must not become weaker for being +written in JavaScript. Types authored in `types.ts` use ordinary TypeScript +syntax and declaration emit. + +#### Prefer inference + +Let TypeScript infer the type of private constants, local variables, and return +types of non-exported functions — write `const f = () => () => null` rather than +`const f: TailReduce = () => () => null`. Add an explicit +annotation only when inference gives the wrong type (e.g. a literal that would +widen — covered below), when the inferred type is not precise enough for a call +site, or on `export`ed declarations where the annotation documents the intended +public contract. Annotating things TypeScript already knows correctly adds noise, +couples the annotation to the implementation, and can introduce `as` casts to +paper over mismatches. + +#### Pin literal `const`s + +A `const` with a **literal** initializer (string / number / bigint / boolean / +array / object literal) must pin its type — either an explicit annotation +(`const a: T = …`) or a trailing `as const`. Never rely on TypeScript's default +widening. + +FunctionalScript data is immutable, but stock `tsc` widens literals by default +(`'2.0'` → `string`, `42n` → `bigint`, `[1, 2]` → `number[]`, dropping +`readonly`), which both misrepresents immutable data and silently breaks literal- +and tuple-dependent typing (`Ts<>` over an rtti schema, tagged-tuple +discriminants in the effect system). The rule scopes to literals because a const +assertion is only legal on a literal or enum member (TS1355) — calls, +conditionals, and references (`or(...)`, `option(...)`, a bare `string`) already +carry precise, non-widening types and are exempt. The mistake is invisible at +runtime (the value is correct; only the type widens), which is exactly why it +must be a style rule. + +Example: `const jsonrpc = '2.0' as const` and +`const request = { jsonrpc, method } as const`, but +`const id = or(string, number, null)` needs nothing. + +#### Avoid `as` type assertions + +Avoid `as` type assertions (except `as const`). Treat them like `unsafe` in Rust +— a last resort that bypasses the type system's safety guarantees and must be +justified. They silence the type checker and hide real bugs; if a cast is needed, +it usually means the types or the code structure should be improved instead. + +The JSDoc equivalent, an inline `/** @type {T} */ (expr)` cast, carries the same +hazard and the same rule: avoid it. Prefer annotating a separate `const` +declaration instead of casting an expression inline — + +```js +/** @type {ReadonlyMap} */ +const empty = new Map() +``` + +rather than + +```js +mapSet(/** @type {ReadonlyMap} */ (new Map()), 'a', 1) +``` + +— because the declaration form documents the variable's intended type and lets +the compiler check the initializer against it (closer to `satisfies`), while the +inline form silently overrides whatever the compiler inferred, exactly like `as`. +Inline `@type` casts carried over from `as` assertions during the +TypeScript-to-JavaScript migration still exist in the tree; converting one to the +declaration form is a welcome cleanup wherever the rewrite is straightforward. + +When the value being narrowed is an invariant a comment would otherwise have to +assert on trust — "this is never `undefined`/`null` because the caller already +guaranteed X" — prefer `assert`/`assertNotNullish` from +[`fjs/asserts/module.f.mjs`](./asserts/module.f.mjs) over a cast: + +```js +const refCounter = assertNotNullish(refs.get(entry)) +``` + +rather than + +```js +const refCounter = /** @type {_RefCounter} */ (refs.get(entry)) +``` + +A cast is a claim the compiler takes on faith and erases at runtime: if the +invariant it documents ever breaks — a future edit to the code it depends on, +a case the original reasoning missed — the narrowed value is silently wrong +instead of the assertion failing where the break actually happened. +`assert`/`assertNotNullish` narrow exactly the same way (via `asserts v` / +a checked return type) but also check the claim every time, so a broken +invariant throws immediately at the point that assumed it, not later at +some unrelated crash site. Reach for a cast only when there is truly no +runtime check to perform — e.g. `@type {const}` below, or narrowing across a +boundary the type system cannot express at all. + +`@type {const}` (the JSDoc equivalent of `as const`, see "Pin literal +`const`s" above) is the one case where this preference inverts: it **must** +stay an inline cast on the expression — +`export const x = /** @type {const} */({ ... })` — and cannot be hoisted to a +leading declaration annotation. `/** @type {const} */` directly above +`export const x = { ... }` makes TypeScript try to resolve `const` as an +ordinary type name and fail with `TS2304: Cannot find name 'const'`; only the +inline-cast position gives it the special const-assertion meaning. This is +unlike every other `@type` cast, which works in both positions — don't +"clean up" a `@type {const}` inline cast into the declaration form. + +#### Prefer `@satisfies` over `@type` when checking, not overriding + +When the goal is to *verify* that an expression matches a shape — not to +*declare* what the compiler should treat it as — use an inline +`/** @satisfies {T} */ (expr)` cast instead of `/** @type {T} */ (expr)`. +`@satisfies` (mirroring TypeScript's `expr satisfies T`) checks assignability +against `T` while keeping the expression's own inferred type; `@type` discards +the inferred type and substitutes `T`, silently absorbing any mismatch instead +of reporting it. If the original TypeScript source used `satisfies`, migrate it +to `@satisfies`, not `@type` — the two are not interchangeable, and swapping one +for the other changes what gets checked. + +This matters most for an expression handed to a generic function, where an +enclosing `@type` cast can strip the very context the function relies on to +check its argument. A cast around a big object literal passed to a +`ToAsyncOperationMap`-shaped parameter, for example, blocks TypeScript from +checking each operation's implementation against `O` — the object literal is no +longer contextually typed by the call site, so a drifted handler shape is +absorbed by the cast instead of flagged. Prefer no cast at all when the callee +already supplies enough context (as `asyncRun(map)` does here) so the object +literal is checked structurally on its own; reach for `@satisfies` only where a +check without adopting the target type is actually wanted, e.g. a value that +must additionally be nominal-branded — `asNominal(x) satisfies T` becomes +`/** @satisfies {T} */ (asNominal(x))`, not `@type`. + +#### Mutually recursive constants: cross-reference with `typeof` + +When exported constants refer to each other in a cycle — the usual shape for a +recursive rtti schema, where `unknown` names `object` and `array` and both are +built from `unknown` — pin them with an explicit `@type` whose element types are +`typeof` references to the other constants, **not** with `@type {const}`: + +```js +/** @type {() => readonly['or', typeof primitive, typeof object, typeof array]} */ +export const unknown = () => ['or', primitive, object, array] + +export const object = record(unknown) +export const array = rttiArray(unknown) +``` + +Forward references are fine: `unknown` is annotated in terms of `object` and +`array`, declared below it. + +`@type {const}` is wrong here even though it compiles. It pins the tuple, so +`npx tsc` and `fjs t` both pass — but it gives declaration emit no *name* for +the recursive positions, so the emitter inlines the structure, gives up at +depth, and writes `/*elided*/ any`. On `fjs/media/json/rtti/module.f.mjs` the +const cast emitted 4 `any` and 2 `/*elided*/`; the `typeof` form emitted +neither. Only a consumer type-checking against the published `.d.mts` sees the +difference, which is why this needs to be a rule rather than something review +catches. Omitting the annotation entirely is a third, louder failure: the array +literal widens to `(string | …)[]` and fails `TS2345` outright (see "Pin literal +`const`s" above). + +Pair the annotation with a round-trip assert so it stays checked rather than +merely claimed — `fjs/media/json/types.ts` holds +`Assert>>`. An explicit `@type` on a constant +whose type the compiler would otherwise infer is only as trustworthy as what +verifies it. + +#### Avoid type predicates + +Avoid TypeScript type predicates (`(x: T): x is U`). They are error-prone: the +compiler trusts the annotation unconditionally, so if the runtime check diverges +from the declared type the error is silent. Use `instanceof` for +class/constructor discrimination, or restructure the union so a structural check +(e.g. `instanceof Array`) narrows correctly without a predicate. + +**Exception:** a type predicate is acceptable when every alternative is +materially worse — in particular when the only other way to narrow is an `as` +cast (which is *unsafe*, strictly worse). Use it only where the predicate body +**is** exactly the structural check that defines membership in `U` (e.g. +`(e: Entity): e is readonly Vec[] => e instanceof Array`), so there is nothing +for the compiler to trust beyond what it could verify itself. Be careful: this +safety is not enforced — if the type definition of `T` or `U` changes later (a +member added to the union, a field's shape changed), the predicate's body can +silently stop matching its asserted type and narrow incorrectly with no compile +error. Keep such predicates next to the type they discriminate, and revisit them +whenever that type changes. + +#### `StringMap` / `RequiredMap` / `OptionalMap` for string-keyed records + +Use the record types from `fjs/types/object/types.ts` for all string-keyed +record types. The key set picks the type: + +- **Open key set:** `StringMap` is `{ readonly[k in string]?: T }` — any + key, every value optional, because "the key may be missing" is what an open + key set means at runtime. +- **Finite key set:** `RequiredMap<'a' | 'b', T>` is + `{ readonly a: T; readonly b: T }`, and `OptionalMap<'a' | 'b', T>` is that + same record with optional values. + +`RequiredMap` is `never`: no object can carry every string as a +required key, so an open key set fails to compile there. Reach for +`StringMap` instead. That guard is `string extends K`, which holds exactly +when `K` is `string` — TypeScript cannot be asked whether a type is finite, so +give `RequiredMap` a union of string literals and nothing else. A template +literal like `` `x-${string}` `` is infinite but passes the guard. + +Do not write inline `{ readonly[k in string]: T }` without `?` — TypeScript +types every access as `T` but the value can be `undefined` at runtime. +**Exception:** mutually-recursive types (e.g. +`type Obj = { readonly[k in string]?: Obj }`) must use the inline form. A type +alias may not reference itself through *another* alias's instantiation, so +`type Obj = StringMap` is TS2456 ("Type alias 'Obj' circularly references +itself") even though it expands to the inline spelling, which resolves. That is +a property of aliasing, not of any one definition — writing the record as a +mapped type rather than a conditional one does not lift it. + +When iterating all defined entries of a `StringMap`, use `definedEntries` +from `fjs/types/object/module.f.mjs` instead of `Object.entries`; use +`definedValues` instead of `Object.values`. + +#### `flatMap` over a filtering type predicate + +Prefer `.flatMap(e => e !== undefined ? [e] : [])` over +`.filter((e): e is T => e !== undefined)` to remove `undefined` entries from an +array. Type predicates in `filter` are error-prone: if the element type changes, +the predicate silently becomes wrong. `flatMap` narrows correctly without a +manual type annotation. + +#### Composition over intersection + +Prefer composition over intersection types. When a type needs an existing record +plus extra fields, embed the record as a named field rather than mixing it in +with `&`. Write `type Signer = { rfc6979: Rfc6979, nf: PrimeField, g: Point }`, +not `type Signer = Rfc6979 & { nf: PrimeField, g: Point }`. + +Intersection blurs where each field came from, couples the composite to the exact +shape of the part, and tempts you to widen the part to fit the whole (e.g. +bolting curve fields onto an `Rfc6979` that is also built and consumed on its own +from a bare subgroup order). A named field keeps the part **unchanged** — +independently constructed and consumed — and reads as plain data you destructure +(`const { rfc6979, nf, g } = signer`). This mirrors the data-first preference +behind avoiding `as` and type predicates: make the structure explicit instead of +deriving it. + +**Exception:** use `&` when every alternative is materially more complex — when +composition would misdescribe the value or push real cost onto callers just to +satisfy the rule. The cases in this repository: + +- **A type-level marker on a value that keeps its own runtime shape.** + `Nominal = symbol & {…}` and + `Phantom = S & { readonly[phantomKey]?: T }` exist precisely because the + value still *is* a `symbol` / an `S` at runtime. A named field would invent a + wrapper that never exists. +- **Describing an object you don't own, or a flat serialized shape.** + `IncomingMessage = Readable & {…}` in `fjs/effects/node/module.mjs` describes + Node's object, which really does carry both member sets on one level. Nesting + the base under a field there would describe something that isn't there — and + for a wire format it would change the encoding, not just the type. +- **A facade adding a member to a generic interface.** + `FileCas = Cas & { url: (v: Vec) => string }` — composition + would route every consumer through an extra hop (`fileCas.cas.read(…)`) to + express one added member. +- **Opening a record type to dynamic keys.** A record type restricts its fields: + unknown keys are neither writable in a literal nor readable off a value. + Intersecting it with `StringMap` keeps the declared fields + checked while allowing arbitrary keys: + + ```ts + type A = { + readonly x: number + } + + // `a` doesn't have access to other fields. + const a: A = { + x: 5, + // b: null, // compilation error + } + // const aB = a.b // compilation error + + // `AM` is a `StringMap` but with restricted fields. + type AM = StringMap & A + + // `am` has access to all fields, with `A`'s restrictions still applied. + const am: AM = { + x: 5, + b: null, + // x: 'no', // compilation error: `x` is still `number` + } + const amB = am.b // `unknown` + ``` + + Reach for this only when the composite type itself must carry both. To hand a + record to something that expects a map, widen at the use site instead — `A` is + already assignable to `StringMap`, so + `const m: StringMap = a` needs no intersection (and no `as`). + +The exception is about cost to the reader or to the runtime, not about `&` being +shorter to type. A composite assembled from record types you define and control — +the `Signer` case above — is still the rule, not the exception. + +#### String literals instead of enum-like aliases + +Use string literals as strongly-typed values directly — don't introduce enum-like +aliases (`enum`, named constants such as `const FOO = 'foo'`) the way other +languages require. TypeScript narrows string literals precisely, so the string +*is* the typed value at runtime. Prefer, in order: + +1. a literal-union type when you only need the type — `type My = 'foo' | 'bar'`; +2. `const my = ['foo', 'bar'] as const` with `type My = typeof my[number]` when + you also need to iterate the values at runtime; +3. `const my = { foo: 'v5', bar: 'v6' } as const` when you need a key→value + mapping (and `keyof typeof my` gives you the key type). + +Existing examples: `os` / `Os` and `architecture` / `Architecture` in +`fjs/ci/common/module.f.mjs`, and `actions` in `fjs/ci/config/module.f.mjs`. + +#### Write the call, not the value it computes + +A value an encoder would produce should be written as that call, not as the +computed result — in source and in proof expectations alike. `range('AF')`, not +`1090519110`. + +The number is derived from an input, and writing it down discards the input that +explains it: nothing recovers `A`–`F` from the digits, and a reader cannot tell a +correct constant from a typo'd one. A named constant does not help — the value is +still hand-computed. This does not apply to numbers that mean themselves: an +index, a count, `0`, `1`. + +When the value sits inside a larger literal, interpolate rather than inline: + +```ts +// avoid +if (r !== '[{"expected":[1090519110]}]') { throw r } +// prefer +if (r !== `[{"expected":[${range('AF')}]}]`) { throw r } +``` + +Tests are the exception where the encoding itself is what's under test. A test +that builds both its input and its expectation from the same encoder cannot +detect a change to it — both sides move together. Some tests may keep +hand-written values to cover that; comment why they are literal. + +### 3.3 Structure and scoping + +#### Import instead of duplicating + +When a sibling module already has the type or helper you need, import it — add +`export` to the existing declaration if it's not yet exported, rather than +duplicating it (e.g. `parse` reuses `Path`, `ValidationError`, `verror`, +`prependPath`, `primitive0Validate`, `constPrimitiveValidate` from `validate`). + +#### Hoist helpers to module scope + +Hoist helpers (functions, types, constants) to module scope when they don't +capture local state — don't redeclare them inside another function on every call. +If a `reduce`/`map` callback needs context that varies per call, thread it +through the accumulator rather than closing over a local, so the step function +itself can live at module scope. + +Treat "doesn't capture local state" as a target to restructure toward, not just a +condition to check: for any nested helper meaningful enough to carry a name, lift +its captures into leading curried parameters and hoist it — even a helper with a +single call site and no per-call cost. A closed, module-scope function has a +context-free identity: content-addressable FunctionalScript can deduplicate +structurally identical closed functions across modules (and repositories), while +a helper that captures enclosing locals hashes uniquely to its context. + +Don't split below the semantic seam, though — if a fragment can't be described by +a one-line JSDoc claim ("renews the lease", "publishes the staging file"), +restructure until it can rather than extracting an unnameable piece. + +#### Hoist call-invariant computations + +If a sub-expression does not depend on a function's parameters, evaluate it once +in the enclosing scope and capture the result instead of recomputing it on every +call. This includes property accesses and destructuring of a module-level value: +prefer `const { listToVec } = msb` at module scope and call `listToVec(x)` over +calling `msb.listToVec(x)` inside a per-call function. + +#### Place curried partial applications at their dependency's scope + +When building a value through a chain of curried partial applications +(`f(a)(b)(c)`), place each partial application at the scope matching what it +depends on. This is not primarily the previous rule's performance concern — it +makes the computation's dependency structure visible: the scope a binding lives +in tells the reader which arguments it needs without tracing the whole call +chain. + +Example (`fjs/basen/module.f.mjs`): `chunkList(msb)` depends on neither `bits` +nor `v`, so it's bound once at module scope (`chunkListMsb`), shared by every +`baseN(...)` codec; `chunkListMsb(bits)` depends on `bits` but not `v`, so it's +applied once inside `baseN`'s body, not once per `vecToString(v)` call. When the +fully-applied chain is itself the thing captured once — assigned directly as an +object property, e.g. +`vecToString: compose(chunkListMsb(bits))(fold(chunkToString)(''))` — naming the +intermediate halves separately adds nothing: the composition already shows, +structurally, that neither operand depends on `v`. Content addressing gives a +second reason beyond readability: each partial application bound at its own scope +is a closed value with its own identity, shareable wherever the same layer +recurs — a monolithic body that re-derives the whole chain per call shares +nothing. + +#### Factor out what two branches share + +When two code branches share most of their structure, refactor so the shared part +appears once and only the difference lives in the conditional. Forcing the reader +to mentally diff two near-identical blocks is a readability cost, not just a DRY +violation. Prefer `{ ...shared, ...(cond ? { extra } : {}) }` over two object +literals that repeat every field, and `cond ? a : b` over duplicated +`if`/`return` arms whose bodies only differ in one expression. Hoist +call-invariant computations above the branch so the conditional contains only +what actually varies. + +#### Prefer destructuring over indexed/property access + +Bind tuple elements and record fields with a pattern +(`const [tag, value] = result`, `const { length, mime_type } = meta`, +`([tag, value]) => …` in a callback) instead of reaching for +`result[0]`/`result[1]`/`obj.field` at each use. It names the parts once, reads +closer to the data's shape, and avoids repeating the container. + +For tagged tuples this is also safe: TypeScript narrows a destructured +discriminated union after a guard on the tag +(`const [tag, value] = result; if (tag === 'error') { … } /* value is the ok payload here */`), +so there is no reason to keep index access for narrowing. **Exception:** when you +genuinely need only one deeply-nested element and a full pattern would be noisier +than a single access. + +### 3.4 Effects (`fjs/effects`) + +Bind every effect in a sequence to its own name, all at one level, so the +sequence reads top-to-bottom in evaluation order instead of inside-out. + +```ts +// avoid +step(a, x => step(f(x), y => step(g(y), z => h(z)))) +// prefer +const x0 = step(a, f) +const x1 = step(x0, g) +return step(x1, h) +``` + +In practice this means not nesting `step` calls from `fjs/effects` — but the +requirement is the *visible sequence*, not the absence of the token `step(` +inside another `step(`. Nesting costs more than indentation: it hides how many +effects run, it puts every continuation's parameter in scope for everything below +it (inviting accidental shadowing), and it makes inserting or reordering a link a +re-indentation of the whole block. + +Lifting a nested continuation into a named helper does **not** satisfy this rule +— `step(a, cont)` with `const cont = x => step(f(x), …)` beta-reduces to the +nested form, so nothing was flattened and the sequence is now split across two +definitions instead of being visible in one. Extract a continuation when it is a +meaningful named operation in its own right, never to relocate a nesting you were +asked to remove. + +#### Reaching back to an earlier value: use `historyStep` + +A later link needing a value from an earlier one is **not** a reason to nest — a +nested continuation only reaches back because it closes over the enclosing scope. +Use `historyStep`, which carries every earlier value forward in a newest-first +tuple (a `History`) so they stay reachable downstream and the chain stays flat. +`history(e)` starts a history from a plain effect; `historyStep` takes a history +and returns one, so it composes with itself to any depth and only the entry point +needs `history`. + +```ts +// avoid — nested only so `h` can still see `x` +step(a, x => step(f(x), y => h(x, y))) +// prefer +const x0 = historyStep(history(a), f) +return step(x0, ([y, x]) => h(x, y)) +``` + +A position in the tuple is distance back from the current link, not evaluation +order, so a destructuring reads reverse-chronologically (`([z, y, x]) => …` binds +`x` earliest). Reaching further back costs an index rather than a traversal, but +a long chain makes the positions hard to count; when that starts to hurt, +collapse it into a record of named fields (`pure({ x, y } as const)`) and start a +fresh history from there. Before reaching for either, check whether the nesting is +forced only by a local declared inside a continuation that doesn't depend on it — +hoist such locals per [§3.3](#hoist-call-invariant-computations) and the nesting +often dissolves on its own. + +#### Why the combinators themselves nest + +This rule governs sequences of effects in **consuming** code, and the combinators +in `fjs/effects` are what make it followable. The nesting has to exist somewhere: +a name cannot be bound to an effect that has not been produced yet, so `f(param)` +cannot become `const x0` until `e` resolves. `step` recurses into itself inside +the continuation it rebuilds, `foldStep` composes one step per item, and +`historyStep` runs `f` inside `e`'s continuation. Each writes that nesting down +**once**, in one line, so no caller ever writes it again. That is the point of the +combinator, not an exemption from the rule: without `historyStep` the rule would +be unfollowable the moment a later link needed an earlier link's value. Read a +nested `step` in a `fjs/effects` combinator as the rule being paid for, and one +anywhere else as the rule being broken. + +### 3.5 FunctionalScript module rules + +Authored FunctionalScript source is JavaScript with JSDoc. Relative +repository-owned dependencies follow these source rules: + +- `.f.mjs` is authored FunctionalScript implementation/proof source, and its + relative runtime imports target `.f.mjs`; +- `types.ts` is authored type-only TypeScript source and carries no runtime + implementation; +- `.f.mjs` — and later `.f.js` — consumes `types.ts` through JSDoc `@import`, + and TypeScript consumes it through `import type`, both always naming the real + `types.ts` file; +- a declaration-only module belongs in `types.ts` rather than acquiring an + artificial runtime representation; +- never add a runtime import/export or runtime value solely to represent a + TypeScript-only type declaration; +- compiler support does not gate the later `.f.mjs` -> `.f.js` rename; + FunctionalScript parser coverage and package support do. + +Avoid references to built-in or external Node modules such as `node:path` in +FunctionalScript source. No `try`/`catch` — see +[§1.5](#15-never-use-trycatch-test-throwing-with-the-throw-key). + +### 3.6 Formatting + +Don't vertically align code with padding spaces (e.g. extra spaces before `:` / +`=` to line up values across rows). It churns on every edit and makes +`git blame` noisy. Write `'actions/checkout': 'v5',` not +`'actions/checkout': 'v5',`. Vertical alignment is fine +in markdown, documentation, and comments. diff --git a/fjs/bnf/todo/669-bnf-matcher-shared-core.md b/fjs/bnf/todo/669-bnf-matcher-shared-core.md index 8c92e3f3b..7589328e0 100644 --- a/fjs/bnf/todo/669-bnf-matcher-shared-core.md +++ b/fjs/bnf/todo/669-bnf-matcher-shared-core.md @@ -149,7 +149,7 @@ export const mrFail = mr(false) `symbolOf` is the one place the two leaf shapes differ, so each backend binds its partial application once at module scope, per -[AGENTS.md §6.3](../../../AGENTS.md#place-curried-partial-applications-at-their-dependencys-scope): +[fjs/AGENTS.md §3.3](../../AGENTS.md#place-curried-partial-applications-at-their-dependencys-scope): `symbolAt(identity)` in `ll1` (`identity` from `fjs/types/function`) and `symbolAt(([symbol]) => symbol)` in `descent`. @@ -167,7 +167,7 @@ Call sites: `leafAt`, and the constructors come from the shared module. - Delete the `AstRuleMeta` / `AstSequenceMeta` / `AstTag` declarations rather than aliasing the new names to the old ones - ([AGENTS.md §5.2](../../../AGENTS.md#52-the-api-is-the-most-important-part-of-quality): + ([DESIGN.md §2](../../../DESIGN.md#2-the-api-is-the-most-important-part-of-quality): two spellings for one concept is the last resort, not the convenient path). There is exactly one external importer to update, `fjs/djs/tokenizer`, which takes `AstRuleMeta`, `AstSequenceMeta`, `AstTag`, and `CodePointMeta` from diff --git a/fjs/bnf/todo/serialized-proof-expectations.md b/fjs/bnf/todo/serialized-proof-expectations.md index addee1a4c..0f10f42b9 100644 --- a/fjs/bnf/todo/serialized-proof-expectations.md +++ b/fjs/bnf/todo/serialized-proof-expectations.md @@ -74,7 +74,7 @@ Decide which of these is true, then apply it uniformly: absent.** This would match how the repo already treats `StringMap` — `{readonly[k in string]?: T}`, iterated with `definedEntries` / `definedValues` precisely because an `undefined` value is not an entry - (AGENTS.md §6.2). It contradicts the semantics `structurallySame` shipped + (`fjs/AGENTS.md` §3.2). It contradicts the semantics `structurallySame` shipped with, so it is a breaking change to that helper and needs its own argument, not a drive-by flip. Note that option 3 also removes the reason option 1 exists, so pick one, not both. @@ -103,5 +103,5 @@ measured against what reads `emptyTag`. - `fjs/cas/evo/proof.f.mjs`, `fjs/bnf/proof.f.mjs` — the two sites already converted; both compared two *values*, so neither hit the `undefined` problem. -- AGENTS.md §6.2 (`StringMap` / `definedEntries`) — the precedent option 3 +- `fjs/AGENTS.md` §3.2 (`StringMap` / `definedEntries`) — the precedent option 3 would be aligning with. diff --git a/fjs/ci/packed-consumer-validation.md b/fjs/ci/packed-consumer-validation.md index 89911a760..b0052c514 100644 --- a/fjs/ci/packed-consumer-validation.md +++ b/fjs/ci/packed-consumer-validation.md @@ -102,7 +102,7 @@ deno check test.ts # and: deno check bad.ts must fail TS2322 A `…/types.js` specifier resolves to the shipped `types.d.ts` for type checking, but names no runtime module — the package ships no `types.js` files. The only supported import is therefore the fully erased `import type { X }` -(or JSDoc `@import`); the rule is stated in AGENTS.md §6.2. The inline form +(or JSDoc `@import`); the rule is stated in `fjs/AGENTS.md` §3.2. The inline form must not be used: `inline.ts`: diff --git a/fjs/common/monoid/types.ts b/fjs/common/monoid/types.ts index 8dfd96439..ebb449151 100644 --- a/fjs/common/monoid/types.ts +++ b/fjs/common/monoid/types.ts @@ -68,7 +68,7 @@ export type Monoid = { * has `null`, meaning "longer than `maxLength`". * * The monoid is carried as a field rather than intersected in, so it stays - * independently constructed and consumed (`AGENTS.md` §6.2). + * independently constructed and consumed (`fjs/AGENTS.md` §3.2). */ export type Absorbing = { readonly monoid: Monoid diff --git a/fjs/effects/eff/README.md b/fjs/effects/eff/README.md index d6124256c..7408e009c 100644 --- a/fjs/effects/eff/README.md +++ b/fjs/effects/eff/README.md @@ -89,7 +89,7 @@ closed, module-scope function has a context-free identity, so content-addressed FunctionalScript can deduplicate structurally identical functions across modules and repositories; a function that captures enclosing locals hashes uniquely to its context (see the hoisting rules in -[`AGENTS.md`](../../../AGENTS.md)). `Eff`'s `.step` is a closure over the chain +[`fjs/AGENTS.md`](../../AGENTS.md)). `Eff`'s `.step` is a closure over the chain it continues, so **every instance is inherently un-shareable** — not merely an allocation, but a value the content-addressed store can never dedupe. Any wrapper of this shape pays that, so it is a property of the approach rather diff --git a/fjs/media/json/rtti/module.f.mjs b/fjs/media/json/rtti/module.f.mjs index 8c3e14e4e..f966f7c56 100644 --- a/fjs/media/json/rtti/module.f.mjs +++ b/fjs/media/json/rtti/module.f.mjs @@ -8,7 +8,7 @@ * `typeof`. That spelling is deliberate: `@type {const}` also type-checks here, * but leaves declaration emit no name for the recursive positions, so it * inlines the structure, gives up at depth, and degrades the emitted `.d.mts` - * to `any`. See AGENTS.md §6.2. + * to `any`. See `fjs/AGENTS.md` §3.2. * * The TypeScript counterparts live in the sibling * [`../types.ts`](../types.ts), which pins them against these schemas with diff --git a/fjs/media/revision/module.f.mjs b/fjs/media/revision/module.f.mjs index cf0324156..3f7b55c88 100644 --- a/fjs/media/revision/module.f.mjs +++ b/fjs/media/revision/module.f.mjs @@ -64,7 +64,7 @@ export const hash = string * schema needs twice over: a `const` cannot reference itself in its own * initializer at all, and naming the recursive position is also what keeps * declaration emit from inlining the structure and giving up at depth (see - * AGENTS.md §6.2 and `../json/rtti/module.f.mjs`). + * `fjs/AGENTS.md` §3.2 and `../json/rtti/module.f.mjs`). * * Like `hash`, this is `string` at the structural level; cbase32 decodability * of every direct value, at every depth, is enforced by diff --git a/fjs/text/utf8/todo/vec-to-code-point-pipeline.md b/fjs/text/utf8/todo/vec-to-code-point-pipeline.md index 8b25fe140..c7d0d13cd 100644 --- a/fjs/text/utf8/todo/vec-to-code-point-pipeline.md +++ b/fjs/text/utf8/todo/vec-to-code-point-pipeline.md @@ -54,7 +54,7 @@ sibling of `fromVec` — mirroring how the encode direction already pairs `tryUtf8`/`utf8` in one place. If it moves, migrate it as a breaking change with every importer updated in the same PR; a re-export left in `fjs/text/module.f.mjs` for existing importers is the stale-re-export case -`AGENTS.md` §8.4 rules out. +`changelog/README.md` rules out. ### Tasks diff --git a/fjs/types/bigfloat/todo/binary64-exponent-range.md b/fjs/types/bigfloat/todo/binary64-exponent-range.md index baf44436d..49d40e3c3 100644 --- a/fjs/types/bigfloat/todo/binary64-exponent-range.md +++ b/fjs/types/bigfloat/todo/binary64-exponent-range.md @@ -65,7 +65,7 @@ const binary64 = { precision: 53, minExp: -1074, maxExp: 971 } i.e. full precision above `minExp + precision`, shrinking to zero at the bottom of the subnormal range. Round to *that* many bits, once. - Above `maxExp`, report overflow. `Nullable` (a `try*`-shaped - result per AGENTS.md §5.6) is the likely shape, leaving the caller to + result per DESIGN.md §6) is the likely shape, leaving the caller to produce an infinity — `BigFloat` has no encoding for one. - `decToBin` stays as the unbounded-exponent entry point (it is the honest answer when the target is not a `double`) and becomes the `precision: 53`, diff --git a/fjs/types/bit_vec/module.f.mjs b/fjs/types/bit_vec/module.f.mjs index a4536458d..fbded318a 100644 --- a/fjs/types/bit_vec/module.f.mjs +++ b/fjs/types/bit_vec/module.f.mjs @@ -183,7 +183,7 @@ const unpackEmpty = /** @type {const} */{ length: 0n, uint: 0n } /** * Neither of these depends on a bit order or on the list being mapped, so both - * are bound once here rather than rebuilt per call (AGENTS.md §6.3). + * are bound once here rather than rebuilt per call (`fjs/AGENTS.md` §3.3). */ const mapUnpack = map(unpack) @@ -207,7 +207,7 @@ const mapU8ToUnpacked = map(u8ToUnpacked) * result because the result is what must not be built: `maxLength` is the * smallest `bigint` size supported across FunctionalScript's runtimes. The two * lengths are exact and additive — this is the length, not an estimate of it - * (AGENTS.md §5.6). + * (`DESIGN.md` §6). * * Being absorbing is also what keeps the fold's walk bounded: `foldAbsorbing` * stops at the first merge that overflows instead of reading the rest of a list diff --git a/fjs/types/btree/todo/proof-tree-corpus.md b/fjs/types/btree/todo/proof-tree-corpus.md index 12e34dfb9..e1fa4fc98 100644 --- a/fjs/types/btree/todo/proof-tree-corpus.md +++ b/fjs/types/btree/todo/proof-tree-corpus.md @@ -213,7 +213,7 @@ are. `reduceValue0` branch-merge path specifically: `test3` is its only exercise, and a corpus swap there would keep the proof green while silently dropping it. -- [ ] Add a CHANGELOG entry (`AGENTS.md` §8.3). The documentation-only +- [ ] Add a CHANGELOG entry (`changelog/README.md`). The documentation-only exemption does not apply: this adds `testlib.f.mjs` and rewrites four `proof.f.mjs` files, which are code changes even though nothing in production moves. diff --git a/fjs/types/list/module.f.mjs b/fjs/types/list/module.f.mjs index a3f120618..d00290155 100644 --- a/fjs/types/list/module.f.mjs +++ b/fjs/types/list/module.f.mjs @@ -238,7 +238,7 @@ export const reduce = op => def => compose(scan(reduceToScan(op)))(last(def)) * Folds `input` with an {@link Accumulator}, short-circuiting to `null` the * moment `update` rejects an item; otherwise finalizes with `end`. This is the * list-level `try*` sibling of {@link fold} for accumulations that can fail - * partway through (see the `try*`/`Nullable` convention in `AGENTS.md`). + * partway through (see the `try*`/`Nullable` convention in `DESIGN.md` §6). * * @type {({ init, update, end }: Accumulator) => (input: List) => Nullable} */ diff --git a/fjs/types/result/module.f.mjs b/fjs/types/result/module.f.mjs index 372c454e2..a76c0cfef 100644 --- a/fjs/types/result/module.f.mjs +++ b/fjs/types/result/module.f.mjs @@ -88,7 +88,7 @@ export const mapOk = f => r => r[0] === 'ok' ? ok(f(r[1])) : r * * Reach for it when the step needs **only** the value it is handed. A chain * whose later steps also read an earlier one's value would have to nest a - * closure per link to keep those values in scope, which AGENTS.md §6.4 rules + * closure per link to keep those values in scope, which `fjs/AGENTS.md` §3.4 rules * out for the same reason it rules out nested `step`; write those as a flat * sequence of guards. * diff --git a/fjs/types/uint8array/module.f.mjs b/fjs/types/uint8array/module.f.mjs index 568fd62f0..9298c0fd7 100644 --- a/fjs/types/uint8array/module.f.mjs +++ b/fjs/types/uint8array/module.f.mjs @@ -29,7 +29,7 @@ const m = map(fromArrayLike) * * Throws if the result would exceed `maxLength`. The bound is not precomputed: * `tryU8ListToVec` attempts the real conversion and reports `null` when it does - * not fit (AGENTS.md §5.6). + * not fit (`DESIGN.md` §6). * * @type {(input: List) => Vec} */ diff --git a/nanvm-lib/AGENTS.md b/nanvm-lib/AGENTS.md new file mode 100644 index 000000000..6cce030f6 --- /dev/null +++ b/nanvm-lib/AGENTS.md @@ -0,0 +1,44 @@ +# Rust (`nanvm-lib/`) + +Rules for the Rust crate — NaNVM, the native FunctionalScript VM. +Repository-wide rules live in the root [AGENTS.md](../AGENTS.md), and the design +principles both code bases follow live in [DESIGN.md](../DESIGN.md). + +## Contents + +1. [Commands](#1-commands) +2. [Coding style](#2-coding-style) + +--- + +## 1. Commands + +```bash +cargo fetch # install dependencies +cargo test # test the nanvm-lib crate +cargo clippy # lint +cargo fmt -- --check # verify formatting +``` + +Run all three checks before submitting any change that touches Rust. + +## 2. Coding style + +### 2.1 Avoid `macro_rules!` + +Avoid `macro_rules!` in Rust code. Declarative macros hide types from +rust-analyzer, break grep and jump-to-definition, and encourage "invisible code" +that contradicts FunctionalScript's preference for explicit, locally-readable +values. When per-type trait boilerplate looks like a macro candidate (e.g. one +impl block per nominal newtype, byte-identical modulo names), prefer in this +order: + +1. a sealed helper trait carrying the variant choice with one-line per-type impls + and a single blanket `impl` deriving the boilerplate; +2. a `build.rs` code generator driven from a small source-of-truth table written + in plain Rust (or a FunctionalScript module if the same table drives other + artifacts too); +3. accept the hand-written duplication as the cost of readability. + +Reach for `macro_rules!` only when no other option is materially better for +readers. diff --git a/todo/README.md b/todo/README.md index fcea51b28..f8fa8e520 100644 --- a/todo/README.md +++ b/todo/README.md @@ -28,6 +28,18 @@ concrete bugs or tasks that belong in a child `todo/`. If you can't decide where an issue belongs, leave it here and discuss. +## GitHub issues are an intake channel + +GitHub issues are an **intake** channel, not a tracker: external contributors +cannot add `todo/` files, so they report there instead (see +[CONTRIBUTING.md](../CONTRIBUTING.md)). A maintainer creates the `todo/` file for +each such report, linking the GitHub issue from its `Related` section. The +`todo/` file is the tracked issue from then on; the GitHub issue stays open only +as the reporter's thread and is closed when the fix ships. + +Reference issues with an explicit link, not GitHub's `#` prefix. `#NNN` is +reserved for GitHub pull request and issue numbers. + ## Blocked by third parties Issues that cannot progress until an external event occurs (a TC39 proposal lands, a @@ -77,7 +89,8 @@ Before deleting, ensure design decisions are captured in the relevant `README.md` or JSDoc. Won't-fix issues: document the reason in the relevant `README.md`, in a code -comment, or in another issue — then delete the issue file. +comment, or in another issue — then delete the issue file. Do not leave a +status-only tombstone. ## Priority scale diff --git a/todo/agents-md-split.md b/todo/agents-md-split.md deleted file mode 100644 index dd58b22b9..000000000 --- a/todo/agents-md-split.md +++ /dev/null @@ -1,162 +0,0 @@ -# Split `AGENTS.md` into scoped documents - -**Priority:** P3 -**Status:** open - -## Problem - -`AGENTS.md` is ~1300 lines, and every agent session loads all of it regardless -of the task. An agent writing Rust in `nanvm-lib/` pays for ~700 lines of -TypeScript/JSDoc coding style; an agent fixing a changelog entry pays for the -effects-chaining rules. The context cost is real (agents have limited context -windows and attention), and a monolithic file also invites drift: some content -already half-duplicates `CONTRIBUTING.md`, `todo/README.md`, and -`changelog/README.md`. - -An agent needs **scoped context**: the root document should carry only the -main principles and a brief per subject — enough to start working — with a -link to the detailed document, which is read only when the task actually -touches that subject. - -## Proposal - -### Target shape of the root `AGENTS.md` - -Follow the pattern already used for issues (`todo/` files co-located with the -code they describe): detailed instructions live next to what they govern, and -the root file becomes a map. - -```markdown -# Header - -Brief (a few sentences) so an agent can start working even after these -sentences. Most important facts here. - -## 1. Specific Subject - -Brief about the specific subject and a link to a more detailed document. - -...try not to make more than 5 sections. -``` - -Rules for the split: - -- **Briefs are concise but simple explanations. No noise.** Each section is a - few sentences: the principle, the one fact an agent must not violate even - without reading further, and the link. -- **One home per fact.** A brief links to the detailed document; it never - restates it, so the two cannot drift apart (the rule `CONTRIBUTING.md` - already declares for itself). -- **Scoped documents are `AGENTS.md` files co-located with the code they - govern** (`fjs/AGENTS.md`, `nanvm-lib/AGENTS.md`). The root brief links to - each scoped file explicitly, so an agent that reads only the root still - finds them by following the link — auto-discovery is not load-bearing. It - is a real optimization where available, though: the nested-`AGENTS.md` - convention is documented at [agents.md](https://agents.md/) ("the closest - file takes precedence" in monorepos) and is followed by Codex and Cursor, - and Claude Code loads nested memory files on demand when working inside a - subtree. Content that already has a natural home in an existing document - (`CONTRIBUTING.md`, `todo/README.md`, `changelog/README.md`, module - `README.md`s) moves there instead of into a new file. -- **At most ~5 sections in the root file**, and the same budget applies to - each scoped document: if `fjs/AGENTS.md` itself grows past what a brief-plus- - links structure can hold, split it the same way one level deeper (e.g. - `fjs/effects/AGENTS.md`) — but only when that pressure is real, not - preemptively. - -### Root `AGENTS.md` outline - -Header brief (before any section): monorepo map (`fjs/` = -FunctionalScript/TypeScript, `nanvm-lib/` = Rust), issues live in `todo/` -directories not on GitHub, the check set to run before submitting -(`npx tsc`, `fjs test`, `cargo test`/`clippy`/`fmt` when Rust is touched), -and the two top principles: simplicity over optimization, and the API is the -most important part of quality. - -1. **Workflow** — issue in `todo/` first, design before implementation, delete - the issue file in the fixing PR. Link: `todo/README.md`. -2. **Environment and running tests** — `npm ci` / `cargo fetch`; one canonical - test command. Link: `CONTRIBUTING.md` (which absorbs the §1 details, e.g. - the twelve-row test-runner table). -3. **FunctionalScript / TypeScript (`fjs/`)** — proofs with 100% coverage are - mandatory; immutability; no `try`/`catch`; JSDoc types. Link: - `fjs/AGENTS.md`. -4. **Rust (`nanvm-lib/`)** — cargo commands; avoid `macro_rules!`. Link: - `nanvm-lib/AGENTS.md`. -5. **Pull requests and releases** — one feature per PR; changelog entry per - code PR; breaking changes are welcome when they improve the API. Links: - `changelog/README.md`, `CONTRIBUTING.md`. - -### Destination map for the current sections - -| Current `AGENTS.md` section | Destination | -| ------------------------------------------- | ------------------------------------------------------------------ | -| §1 Development environment (incl. §1.4 runner table, §1.5 updates) | `CONTRIBUTING.md` (already covers half of it) | -| §1.6 Rust commands | `nanvm-lib/AGENTS.md` | -| §2 Everyday workflow | root brief (it is short and is the "most important facts") | -| §3 Testing and proof coverage | `fjs/AGENTS.md` (extension contract details stay in `fjs/fsc/README.md`) | -| §4 Documentation (JSDoc/module headers) | `fjs/AGENTS.md` | -| §5 Design principles | §5.1–5.2 condensed into the root brief; the full text plus §5.3–5.8 into a linked design document (see open question below) | -| §6 Coding style §6.1–6.6 | `fjs/AGENTS.md` | -| §6.7 Rust | `nanvm-lib/AGENTS.md` | -| §7 Issues | root brief + the filing table merged into `todo/README.md` | -| §8 Pull requests, changelog, versioning | §8.3 entry rules and §8.4 versioning into `changelog/README.md`; §8.1–8.2 stay as the root brief | -| §8.5 Commit messages (in flight in [#1561](https://github.com/functionalscript/functionalscript/pull/1561)) | `CONTRIBUTING.md` — the title-form and squash-merge rules are PR-process, not changelog content. `todo/commit-message-enforcement.md` will lint against wherever it lands, so repoint that issue's links in the same PR. | - -### Open questions - -- **Where do repo-wide design principles (§5) live in full?** They apply to - both codebases, so neither `fjs/AGENTS.md` nor `nanvm-lib/AGENTS.md` is - right. Options: keep them as the one long section of the root file - (weakens the "brief only" rule), or a dedicated linked document (e.g. - `doc/design.md` — introduces a new directory). Decide before implementing. -- **Does `nanvm-lib/AGENTS.md` need more than §1.6 + §6.7?** Probably yes - eventually (error-handling patterns, testing conventions live in - `nanvm-lib/todo/` issues today), but the split PR should only move existing - text, not write new guidance. - -### Migration rules - -- Move text, don't rewrite it — a pure relocation PR is reviewable; combined - relocation-plus-editing is not. Tightening a moved section is a follow-up. -- Fix all inbound links in the same PR. Exactly four files link to - `AGENTS.md#` anchors today — `CONTRIBUTING.md` (five anchors), - `docker/README.md`, `changelog/README.md`, and - `fjs/bnf/todo/669-bnf-matcher-shared-core.md` — so no redirect stubs are - needed; update the links to the new homes. Two caveats: the `bnf` issue - links to a deep `####` anchor inside §6.3 and to §5.2's own heading, and - the §5.2 one cannot be repointed until the §5 open question is decided; and - [#1561](https://github.com/functionalscript/functionalscript/pull/1561) - adds more `AGENTS.md#` links (from `CONTRIBUTING.md` and two - commit-message `todo/` files), so re-run the inventory - (`grep -rn 'AGENTS\.md#' --include='*.md'`) when implementation starts. -- No CHANGELOG entry: documentation-only PR. - -## Tasks - -- [ ] Decide the home for the full §5 design-principles text (open question - above). -- [ ] Move §1 environment details into `CONTRIBUTING.md`, deduplicating with - what it already says. -- [ ] Create `fjs/AGENTS.md` from §3, §4, §6.1–6.6. -- [ ] Create `nanvm-lib/AGENTS.md` from §1.6 and §6.7. -- [ ] Merge the §7 filing table into `todo/README.md`; move §8.3–8.4 into - `changelog/README.md` and §8.5 (once - [#1561](https://github.com/functionalscript/functionalscript/pull/1561) - lands) into `CONTRIBUTING.md`. -- [ ] Rewrite the root `AGENTS.md` as the header brief plus ≤5 brief+link - sections per the outline above. -- [ ] Re-run the inbound-link inventory, then update every file with - `AGENTS.md#` anchor links (today: `CONTRIBUTING.md`, - `docker/README.md`, `changelog/README.md`, - `fjs/bnf/todo/669-bnf-matcher-shared-core.md`). -- [ ] Verify each moved fact exists in exactly one place (grep for duplicated - sentences between root, scoped files, and `CONTRIBUTING.md`). - -## Related - -- [todo/README.md](./README.md) — the co-location pattern this split follows. -- [CONTRIBUTING.md](../CONTRIBUTING.md) — declares the "link, don't restate" - anti-drift rule and absorbs the environment details. -- [changelog/README.md](../changelog/README.md) — absorbs the changelog-entry - and versioning rules. diff --git a/todo/commit-message-enforcement.md b/todo/commit-message-enforcement.md index 032f73e2e..ced060db7 100644 --- a/todo/commit-message-enforcement.md +++ b/todo/commit-message-enforcement.md @@ -2,7 +2,7 @@ **Priority:** P3 **Status:** open — the format is adopted, in -[AGENTS.md §8.5](../AGENTS.md#85-commit-messages), so this is no longer +[CONTRIBUTING.md](../CONTRIBUTING.md#commit-messages), so this is no longer waiting on it; the linter enforces that documented rule. The gap between adoption and enforcement is deliberate trial time, so let the format run by hand on real PRs first: whatever it gets wrong is fixed while a fix is still a @@ -49,7 +49,7 @@ would block it outright but require an Enterprise plan. ## Related -- [AGENTS.md §8.5](../AGENTS.md#85-commit-messages) — the format this enforces +- [CONTRIBUTING.md](../CONTRIBUTING.md#commit-messages) — the format this enforces - [commit-message-standard.md](./commit-message-standard.md) — the reasoning behind that format, and the repository settings it still waits on - [changelog-website.md](./changelog-website.md) — plans the changelog diff --git a/todo/commit-message-standard.md b/todo/commit-message-standard.md index 0469a7281..881a1f74a 100644 --- a/todo/commit-message-standard.md +++ b/todo/commit-message-standard.md @@ -2,9 +2,9 @@ **Priority:** P2 **Status:** wip — the format is adopted, in -[AGENTS.md §8.5](../AGENTS.md#85-commit-messages): title, `Changelog:` section, -squash-only. Release tagging was rejected, see below. AGENTS.md is the -normative text from now on; the proposal below is kept for the reasoning +[CONTRIBUTING.md](../CONTRIBUTING.md#commit-messages): title, `Changelog:` +section, squash-only. Release tagging was rejected, see below. +`CONTRIBUTING.md` is the normative text from now on; the proposal below is kept for the reasoning behind it, and only the repository settings remain undone — they need a maintainer with admin rights and cannot land in a PR. @@ -111,18 +111,18 @@ holds one file per PR that shipped in that release — so a tag would be a second copy of a fact the tree already carries, and one a release could forget. A generator takes the boundary from the release commit, whose title is the bare version, or from the changelog directories themselves. Recorded in -[AGENTS.md §8.4](../AGENTS.md#84-breaking-changes-and-versioning) so the +[changelog/README.md](../changelog/README.md#breaking-changes-and-versioning) so the question is not reopened by the next reader. ## Tasks - [ ] Repository settings: squash-only, default squash message "Pull request title and description", branch protection (PRs required, linear - history). Until these are set, AGENTS.md §8.5 is a convention a + history). Until these are set, the documented format is a convention a maintainer can defeat with one click in the merge dialog or one `git push`. -- [x] Document the title and `Changelog:` section format in AGENTS.md §8 once - adopted — [§8.5](../AGENTS.md#85-commit-messages) +- [x] Document the title and `Changelog:` section format once adopted — + [CONTRIBUTING.md](../CONTRIBUTING.md#commit-messages) Machine-checking the format before merge is a separate, later step: [commit-message-enforcement.md](./commit-message-enforcement.md), unblocked diff --git a/todo/migrate-typescript-to-mjs.md b/todo/migrate-typescript-to-mjs.md index dfd786057..b35bdfb8b 100644 --- a/todo/migrate-typescript-to-mjs.md +++ b/todo/migrate-typescript-to-mjs.md @@ -988,7 +988,7 @@ person can re-check rather than re-derive. Counts are as of declarations already in the tree re-checks every `.mjs` import through the emitted `.d.mts` (which outranks `.mjs` in resolution), which is what made the `Assert>` round-trip pins bite — seeding the - AGENTS.md §6.2 counter-example fails the two-pass `prepack` and passes a + `fjs/AGENTS.md` §3.2 counter-example fails the two-pass `prepack` and passes a naive one-pass one. `prepack` is therefore `tsc --noEmit false --emitDeclarationOnly && tsc`: declaration emit, then the same round-trip check with nothing emitted.