Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@
"run": "deno install --frozen"
},
{
"run": "deno test --allow-read --allow-env --allow-sys --coverage && deno coverage --include='.*module\\.f\\.ts'"
"run": "deno test --allow-read --allow-env --allow-sys --coverage && deno coverage --include='.*module\\.f\\.(ts|mjs)'"
}
]
},
Expand Down
46 changes: 36 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ cargo fmt -- --check # verify formatting

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.f.ts` for every new `.f.ts` module
([§3](#3-testing-and-proof-coverage)).
2. Write the code, plus a co-located proof for every new `.f.ts` or `.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
Expand All @@ -142,18 +142,44 @@ cargo fmt -- --check # verify formatting

- `npx tsc` — type-check using the repository's version of TypeScript.
- `fjs t` (or any equivalent from [§1.4](#14-ways-to-run-the-functionalscript-test-suite))
— test FunctionalScript (`.f.ts`) files.
— test FunctionalScript (`.f.ts` / `.f.mjs`) files.
- `cargo test`, `cargo clippy`, `cargo fmt -- --check` — the Rust crate.

### 3.2 Proof coverage is mandatory

New FunctionalScript (`.f.ts`) 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. A new
`module.f.ts` ships with a co-located `proof.f.ts` (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.
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 both
authored FunctionalScript extensions, `.f.ts` and `.f.mjs`
([`fjs/fsc/README.md`](./fjs/fsc/README.md) defines them). 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.

The implementation and proof extensions are independent during the incremental
`.f.mjs` migration:

| Implementation | Proof | When |
|---|---|---|
| `module.f.ts` | `proof.f.ts` | Default. |
| `module.f.mjs` | `proof.f.ts` | A module migrated to `.f.mjs` keeps its TypeScript proof, which may still import `.f.ts` test helpers such as `fjs/asserts/module.f.ts`. |
| `module.f.mjs` | `proof.f.mjs` | Once the proof's own syntax and relative FunctionalScript dependencies are compiler-ready. |

Renaming an implementation to `.f.mjs` therefore never requires renaming its
proof, and never removes it from proof discovery or from Node and Deno coverage:
`shouldLoad` in [`fjs/dev/module.f.ts`](./fjs/dev/module.f.ts) matches both
authored extensions, and both `npm run cov` and `deno task cov` include
`module.f.ts` and `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, so it must satisfy the same
dependency-closure rule as any other migrated file — its relative imports and
type references may target `.f.mjs` modules only. That rule is what makes
`proof.f.ts` the default layout for a migrated module: a TypeScript proof can
keep using the existing `.f.ts` test helpers. See
[`fjs/fsc/README.md`](./fjs/fsc/README.md) for the migration order and the
module-import policy it implies.

### 3.3 Use `assert` / `assertEq`, never a hand-written `if`/`throw`

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ history.

## Unreleased

- Test and coverage tooling recognizes authored `.f.mjs` FunctionalScript
modules: proof discovery loads them, and `npm run cov`, `deno task cov`, and
the generated Deno CI step include `module.f.mjs` alongside `module.f.ts`
[#1422](https://github.com/functionalscript/functionalscript/pull/1422)

### 0.41.0

- `fjs/effects`: `match` resolves a command's handler with an own-property
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ cargo fmt -- --check
Bun, and published-CLI equivalents are listed in
[AGENTS.md §1.4](./AGENTS.md#14-ways-to-run-the-functionalscript-test-suite).

New `.f.ts` modules need a co-located `proof.f.ts` with 100% proof coverage —
see [AGENTS.md §3](./AGENTS.md#3-testing-and-proof-coverage).
New `.f.ts` and `.f.mjs` modules need a co-located proof with 100% proof
coverage — see [AGENTS.md §3](./AGENTS.md#3-testing-and-proof-coverage). A
`module.f.mjs` may keep a `proof.f.ts` during the incremental `.f.mjs`
migration, or use a `proof.f.mjs` once that proof is itself compiler-ready.

### Updating dependencies

Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"tasks": {
"fjs": "deno run --allow-read --allow-write --allow-env --allow-net --allow-sys ./fjs/module.ts",
"test": "deno test --allow-read --allow-env --allow-sys",
"cov": "deno test --allow-read --allow-env --allow-sys --coverage && deno coverage --include='.*module\\.f\\.ts'"
"cov": "deno test --allow-read --allow-env --allow-sys --coverage && deno coverage --include='.*module\\.f\\.(ts|mjs)'"
},
"fmt": {
"indentWidth": 4,
Expand Down
11 changes: 10 additions & 1 deletion fjs/ci/deno/module.f.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import { type MetaStep, install, test, uses } from '../common/module.f.ts'

const denoTest = 'deno test --allow-read --allow-env --allow-sys' as const

/**
* The regular expression selecting FunctionalScript implementation modules for
* Deno coverage. Both authored extensions are included so a module migrated
* from `.f.ts` to `.f.mjs` stays in the report. Keep it semantically equal to
* the `--test-coverage-include` list in `package.json` and to the `cov` task in
* `deno.json`.
*/
export const coverageInclude = '.*module\\.f\\.(ts|mjs)' as const

export const denoSteps = (version: string): readonly MetaStep[] => [
install(uses('denoland/setup-deno', { 'deno-version': deno })),
// We need --minimum-dependency-age=0 for functionalscript because we would like to use
Expand All @@ -18,5 +27,5 @@ export const denoSteps = (version: string): readonly MetaStep[] => [
install({ run: `deno install -g -A --minimum-dependency-age=0 npm:functionalscript@${version}` }),
test({ run: `deno run -A --minimum-dependency-age=0 npm:functionalscript@${version} t` }),
test({ run: 'deno install --frozen' }),
test({ run: `${denoTest} --coverage && deno coverage --include='.*module\\.f\\.ts'` }),
test({ run: `${denoTest} --coverage && deno coverage --include='${coverageInclude}'` }),
]
29 changes: 29 additions & 0 deletions fjs/ci/deno/proof.f.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { coverageInclude, denoSteps } from './module.f.ts'
import { toSteps } from '../common/module.f.ts'
import { assert, assertEq } from '../../asserts/module.f.ts'

const coverageRuns = (version: string): readonly string[] =>
toSteps(denoSteps(version))
.flatMap(s => s.run !== undefined && s.run.includes('deno coverage') ? [s.run] : [])

export const proof = {
// A regression guard: dropping either authored implementation extension
// from the Deno coverage filter silently removes those modules from the
// CI coverage report while CI still passes.
coverageInclude: () => {
assertEq(coverageInclude, '.*module\\.f\\.(ts|mjs)')
},
coverageStep: () => {
const runs = coverageRuns('0.0.0')
assertEq(runs.length, 1)
const [run] = runs
assert(run !== undefined)
assert(run.includes(`deno coverage --include='${coverageInclude}'`))
},
installsPinnedVersion: () => {
const runs = toSteps(denoSteps('1.2.3'))
.flatMap(s => s.run !== undefined && s.run.includes('npm:functionalscript@') ? [s.run] : [])
assertEq(runs.length, 2)
assert(runs.every(r => r.includes('npm:functionalscript@1.2.3')))
},
}
3 changes: 2 additions & 1 deletion fjs/ci/proof.f.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ci, main } from './module.f.ts'
import { functionalscript, node } from './config/module.f.ts'
import { nodeNixJobs } from './node/module.f.ts'
import { coverageInclude } from './deno/module.f.ts'
import { utf8, utf8ToString } from '../text/module.f.ts'
import { empty as emptyVec, isVec } from '../types/bit_vec/module.f.ts'
import { type MetaStep, type Os, test, ubuntu, type GitHubAction, parseGitHubAction } from './common/module.f.ts'
Expand Down Expand Up @@ -149,7 +150,7 @@ export const proof = {
assert(hasRun(`deno install -g -A --minimum-dependency-age=0 npm:functionalscript@${functionalscript}`)(gha), 'expected configured-version deno install cache')
assert(hasRun('deno install --frozen')(gha), 'expected deno lock install')
assert(hasRun(`deno run -A --minimum-dependency-age=0 npm:functionalscript@${functionalscript} t`)(gha), 'expected configured-version deno install')
assert(hasRun("deno test --allow-read --allow-env --allow-sys --coverage && deno coverage --include='.*module\\.f\\.ts'")(gha), 'expected limited-permission deno coverage')
assert(hasRun(`deno test --allow-read --allow-env --allow-sys --coverage && deno coverage --include='${coverageInclude}'`)(gha), 'expected limited-permission deno coverage')
assert(hasRun(`bun install -g functionalscript@${functionalscript}`)(gha), 'expected configured-version bun cache')
assert(hasRun('bun install --frozen-lockfile')(gha), 'expected bun lock install')
assert(hasRun(`bunx functionalscript@${functionalscript} t`)(gha), 'expected configured-version bun install')
Expand Down
19 changes: 14 additions & 5 deletions fjs/ci/todo/f-mjs-package-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,20 @@ the dependency-closure invariant for compiler-ready `.f.mjs` source.
### Ordering

Complete this task, including repeatable emission and the `AGENTS.md`
module-import policy update, together with
[`.f.mjs` test and coverage support](../../emergent_testing/todo/f-mjs-test-and-coverage.md),
before converting the first existing repository module from `.f.ts` to
`.f.mjs`. A synthetic compiler fixture that does not enter the published runtime
graph may be used earlier.
module-import policy update, before converting the first existing repository
module from `.f.ts` to `.f.mjs`. A synthetic compiler fixture that does not
enter the published runtime graph may be used earlier.

This task now comes **first** among the two `.f.mjs` prerequisites. The tooling
half of
[`.f.mjs` test and coverage support](../../emergent_testing/todo/f-mjs-test-and-coverage.md)
— proof discovery, Node and Deno coverage, the generated CI step, and the
`AGENTS.md`/`CONTRIBUTING.md` proof policy — has shipped; what remains there are
the runtime fixtures, which cannot land until authored `.mjs` is a type-checked,
packable source extension. Enabling `allowJs`/`checkJs` on its own is not enough:
`npm run prepack` (`tsc --NoEmit false`) then fails with TS5055 because authored
`.mjs` becomes both an input and a JavaScript emit target, which is what the
cleanup and split-emission tasks above fix.

### Related

Expand Down
37 changes: 30 additions & 7 deletions fjs/dev/module.f.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,28 @@ export type ModuleMap = StringMap<string, Module>
/**
* Returns `true` if the file should be loaded for proof discovery.
*
* All FunctionalScript modules (`.f.ts` / `.f.js`) are safe to bulk-load by
* construction — they have no import side effects. For vanilla TS/JS the
* load gate stays opt-in by filename: any file ending in `proof.ts`,
* `proof.js`, `proof.mts`, or `proof.mjs` is included.
* Two symmetrical rules, each covering all four TS/JS module extensions:
*
* - **FunctionalScript modules** — anything ending in `.f.ts`, `.f.mts`,
* `.f.js`, or `.f.mjs`. They are safe to bulk-load by construction, since
* they have no import side effects, so the whole module is loaded and its
* internal `proof` export (if any) is discovered. `.f.ts` and `.f.mjs` are
* the authored extensions; `.f.js` is generated from `.f.ts`.
* - **Impure JavaScript/TypeScript proofs** — anything ending in `proof.ts`,
* `proof.mts`, `proof.js`, or `proof.mjs`. Outside FunctionalScript a module
* may have import side effects, so the load gate stays opt-in by filename.
*
* A `proof.f.mts` / `proof.f.mjs` matches the FunctionalScript rule by its
* `.f.` infix, not the vanilla proof rule.
*
* Whether a loaded module actually _contains_ a proof is determined at
* runtime by checking for an exported `proof` property.
*/
export const shouldLoad = (s: string): boolean =>
s.endsWith('.f.ts') || s.endsWith('.f.js') ||
s.endsWith('proof.ts') || s.endsWith('proof.js') ||
s.endsWith('proof.mts')|| s.endsWith('proof.mjs')
s.endsWith('.f.ts') || s.endsWith('.f.mts') ||
s.endsWith('.f.js') || s.endsWith('.f.mjs') ||
s.endsWith('proof.ts') || s.endsWith('proof.mts')||
s.endsWith('proof.js') || s.endsWith('proof.mjs')

const isSourceFile = (path: string): boolean =>
path.endsWith('.js') || path.endsWith('.ts') || path.endsWith('.mts') || path.endsWith('.mjs')
Expand Down Expand Up @@ -132,6 +142,19 @@ export const proof = {
assert(!isSourceFile('readme.md'))
assert(!isSourceFile('module.json'))
},
allFilesFindsFunctionalScript: () => {
// Every FunctionalScript extension is discovered, so a module migrated
// from `.f.ts` to `.f.mjs` keeps its proofs. An ordinary `.mjs` is
// still skipped.
const root: Dir = {
'a.f.ts': [],
'b.f.mjs': [],
'c.f.mts': [],
'd.mjs': [],
}
const [, result] = virtual({ ...emptyState, root })(allFiles('.', shouldLoad))
assertEq(result.join(','), './a.f.ts,./b.f.mjs,./c.f.mts')
},
allFilesSkipsNodeModules: () => {
// `node_modules` is skipped without descending into it, even though
// it contains a file that would otherwise match the predicate.
Expand Down
11 changes: 10 additions & 1 deletion fjs/dev/proof.f.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@ import { shouldLoad } from './module.f.ts'

export const proof = {
shouldLoad: () => {
// Every FunctionalScript extension is loaded whatever the file name.
assert(shouldLoad('foo.f.ts'))
assert(shouldLoad('foo.f.mts'))
assert(shouldLoad('bar.f.js'))
assert(shouldLoad('module.f.mjs'))
assert(shouldLoad('proof.f.mts'))
assert(shouldLoad('proof.f.mjs'))
// Every impure JS/TS extension is loaded only under the `proof` name.
assert(shouldLoad('proof.ts'))
assert(shouldLoad('proof.js'))
assert(shouldLoad('proof.mts'))
assert(shouldLoad('proof.js'))
assert(shouldLoad('proof.mjs'))
assert(!shouldLoad('module.ts'))
assert(!shouldLoad('module.mts'))
assert(!shouldLoad('module.js'))
assert(!shouldLoad('module.mjs'))
assert(!shouldLoad('readme.md'))
},
shouldPass: () => ({
Expand Down
Loading
Loading