From d1cbedd247039a540466efd240d7cffd5476f029 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 04:38:11 +0000 Subject: [PATCH 001/370] Make the browser test page idle by default and rename Run The page no longer auto-runs its suite on load or via a query parameter; it starts idle and waits for an explicit click. The button is renamed from "Run again" to "Run", and the runner itself now keeps it genuinely disabled (not just click-ignoring) while a suite is loading or running, re-enabling it on every terminal state so the same action starts every run. Cancellation and its Cancel button are left for a follow-up, per the todo. --- fjs/emergent_testing/browser.mjs | 22 ++++++- fjs/emergent_testing/browser/proof.mjs | 58 ++++++++++++++++++- .../todo/browser-test-controls.md | 10 ++-- fjs/website/module.f.mjs | 15 ++--- 4 files changed, 85 insertions(+), 20 deletions(-) diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index ccc09e562..2683bfae6 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -380,8 +380,26 @@ export const startBrowserTestSources = (root, sources, importer) => { return report } -/** @type {(root: Element, state: string) => void} */ -const setState = (root, state) => root.setAttribute('data-state', state) +/** + * Sets the runner state and keeps the `Run` control's real disabled state in + * sync with it: passive while a suite is loading or running, active in every + * other state (idle, or any terminal status). A disabled attribute is used + * rather than a click handler that silently ignores the action, so assistive + * technology sees the same unavailability a sighted user does. + * + * @type {(root: Element, state: string) => void} + */ +const setState = (root, state) => { + root.setAttribute('data-state', state) + const runButton = root.querySelector('[data-test-run]') + if (runButton !== null) { + if (state === 'loading' || state === 'running') { + runButton.setAttribute('disabled', '') + } else { + runButton.removeAttribute('disabled') + } + } +} /** * Renders a completed report in the browser test page. diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 52f7ab9b3..eb82bd93c 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -12,7 +12,7 @@ import { runInNewContext } from 'node:vm' import { assert, assertEq, assertNotNullish, assertStructurallySame } from '../../asserts/module.f.mjs' import { renderBrowserReport, runBrowserProofs, startBrowserTests, startBrowserTestSources } from '../browser.mjs' -/** @typedef {{ readonly tag: string, readonly attributes: Map, readonly ownerDocument: _Document, textContent: string, children: readonly _Element[], readonly setAttribute: (name: string, value: string) => void, readonly querySelector: (selector: string) => _Element | null, readonly replaceChildren: (...nodes: readonly _Element[]) => void, readonly append: (node: _Element) => void }} _Element */ +/** @typedef {{ readonly tag: string, readonly attributes: Map, readonly ownerDocument: _Document, textContent: string, children: readonly _Element[], readonly setAttribute: (name: string, value: string) => void, readonly removeAttribute: (name: string) => void, readonly querySelector: (selector: string) => _Element | null, readonly replaceChildren: (...nodes: readonly _Element[]) => void, readonly append: (node: _Element) => void }} _Element */ /** @typedef {{ defaultView: _View | null, readonly createElement: (tag: string) => _Element }} _Document */ /** @typedef {{ events: readonly CustomEvent[], readonly dispatchEvent: (event: Event) => boolean, fjsBrowserTestReport?: Promise }} _View */ @@ -37,6 +37,7 @@ const element = (document, tag, attributes, states) => { if (name === 'data-state') { states.push(value) } self.attributes.set(name, value) }, + removeAttribute: name => { self.attributes.delete(name) }, // The runner only ever queries an attribute selector of `[name]` form. querySelector: selector => self.children.reduce( (/** @type {_Element | null} */ acc, child) => @@ -53,7 +54,7 @@ const element = (document, tag, attributes, states) => { * paragraph and the result list. `states` records every `data-state` written, * so a proof can check the whole progression and not just its last step. * - * @type {(withView?: boolean) => { readonly root: Element, readonly summary: _Element, readonly results: _Element, readonly view: _View, readonly states: readonly string[] }} + * @type {(withView?: boolean) => { readonly root: Element, readonly summary: _Element, readonly results: _Element, readonly runButton: _Element, readonly view: _View, readonly states: readonly string[] }} */ const page = (withView = true) => { /** @type {string[]} */ @@ -75,11 +76,13 @@ const page = (withView = true) => { const root = element(document, 'main', ['data-browser-tests'], states) root.replaceChildren( element(document, 'p', ['data-test-summary'], states), + element(document, 'button', ['data-test-run'], states), element(document, 'ol', ['data-test-results'], states)) return { root: /** @type {Element} */ (/** @type {unknown} */ (root)), summary: assertNotNullish(root.querySelector('[data-test-summary]')), results: assertNotNullish(root.querySelector('[data-test-results]')), + runButton: assertNotNullish(root.querySelector('[data-test-run]')), view, states, } @@ -331,6 +334,57 @@ export const proof = { assertStructurallySame([...p.states], ['loading', 'infrastructure-error']) assertEq(p.view.events.length, 1) }, + runControlIdle: () => { + // The page starts idle: no run is underway, so `Run` is active and + // carries no `disabled` attribute at all — not merely an unchecked one. + const p = page() + assertEq(p.states.length, 0) + assertEq(p.runButton.attributes.has('disabled'), false) + }, + runControlDisabledWhileActive: async () => { + // `Run` must be passive — genuinely disabled, not just click-ignoring — + // for the whole span between a click and the next terminal state: + // through loading and through execution. + const p = page() + /** @type {(module: { readonly proof?: unknown }) => void} */ + let release = () => undefined + /** @type {Promise<{ readonly proof?: unknown }>} */ + const pending = new Promise(resolve => { release = resolve }) + const done = startBrowserTestSources(p.root, ['a.mjs'], () => pending) + await Promise.resolve() + assertEq(p.states[0], 'loading') + assertEq(p.runButton.attributes.has('disabled'), true) + release({ proof: { t: () => undefined } }) + await Promise.resolve() + await Promise.resolve() + assertEq(p.runButton.attributes.has('disabled'), true) + const report = await done + assertEq(report.status, 'passed') + // Terminal state hands control back: a new run can be started. + assertEq(p.runButton.attributes.has('disabled'), false) + }, + runControlReenabledAfterFailure: async () => { + // A failed or infrastructure-error run is just as terminal as a passed + // one: `Run` reactivates either way. + const p = page() + const report = await startBrowserTestSources(p.root, ['bad.mjs'], + source => Promise.reject(new Error(`offline: ${source}`))) + assertEq(report.status, 'infrastructure-error') + assertEq(p.runButton.attributes.has('disabled'), false) + }, + runControlNewRunAfterCompletion: async () => { + // The same action starts every run: nothing but the `Run` control's + // own state stands between a completed run and the next one. + const p = page() + await startBrowserTestSources(p.root, ['a.mjs'], + () => Promise.resolve({ proof: { t: () => undefined } })) + assertEq(p.runButton.attributes.has('disabled'), false) + const second = await startBrowserTestSources(p.root, ['a.mjs'], + () => Promise.resolve({ proof: { t: () => undefined } })) + assertEq(second.status, 'passed') + assertStructurallySame([...p.states], + ['loading', 'running', 'passed', 'loading', 'running', 'passed']) + }, sourcesLoadFailure: async () => { const p = page() const report = await startBrowserTestSources(p.root, ['ok.mjs', 'bad.mjs'], diff --git a/fjs/emergent_testing/todo/browser-test-controls.md b/fjs/emergent_testing/todo/browser-test-controls.md index c887bfa26..32bce318f 100644 --- a/fjs/emergent_testing/todo/browser-test-controls.md +++ b/fjs/emergent_testing/todo/browser-test-controls.md @@ -52,17 +52,17 @@ module or a default query parameter. ### Tasks -- [ ] Remove the entry module's automatic `start()` call. -- [ ] Rename `Run again` to `Run`. +- [x] Remove the entry module's automatic `start()` call. +- [x] Rename `Run again` to `Run`. - [ ] Add a `Cancel` button and implement the inverse enabled/disabled states for `Run` and `Cancel`. - [ ] Add a per-run cancellation token or equivalent identity checked during loading, between execution batches, and before every UI/global/event publication. - [ ] Define the serializable cancelled report and completion-event behavior. -- [ ] Prove initial idle behavior, both buttons' state transitions, - cancellation during loading, cancellation during proof execution, and a - new run after cancellation. +- [x] Prove initial idle behavior and `Run`'s state transitions across + loading, running, and both terminal outcomes; cancellation-related + proofs are deferred with the `Cancel` button above. ### Related diff --git a/fjs/website/module.f.mjs b/fjs/website/module.f.mjs index 231ef82d6..24d569c1d 100644 --- a/fjs/website/module.f.mjs +++ b/fjs/website/module.f.mjs @@ -28,7 +28,7 @@ body { background-color: var(--bg); color: var(--text); font: 16px system-ui; ma pre { white-space: pre-wrap } `] )( - ['main', { 'data-browser-tests': '', 'data-state': 'loading' }, + ['main', { 'data-browser-tests': '', 'data-state': 'idle' }, ['p', ['a', { href: 'https://github.com/functionalscript/functionalscript' }, 'GitHub Repository' @@ -42,8 +42,8 @@ pre { white-space: pre-wrap } ], '.' ], - ['p', { 'data-test-summary': '' }, 'Loading…'], - ['button', { type: 'button', 'data-test-run': '' }, 'Run again'], + ['p', { 'data-test-summary': '' }, 'Idle. Press Run to start the suite.'], + ['button', { type: 'button', 'data-test-run': '' }, 'Run'], ['pre', ['ol', { 'data-test-results': '' }]] ], ['script', { type: 'module', src: './_browser-test-entry.mjs' }] @@ -55,15 +55,8 @@ import { browserProofSources } from './fjs/emergent_testing/_browser-suite.mjs' const root = /** @type {Element} */ (document.querySelector('[data-browser-tests]')) const sources = [...browserProofSources, './fjs/website/browser.mjs'] const runButton = /** @type {Element} */ (document.querySelector('[data-test-run]')) -const start = () => { - runButton.setAttribute('disabled', '') - return startBrowserTestSources(root, sources, source => import(source)).then(report => { - runButton.removeAttribute('disabled') - return report - }) -} +const start = () => startBrowserTestSources(root, sources, source => import(source)) runButton.addEventListener('click', start) -if (new URL(location.href).searchParams.get('run') !== 'false') { start() } `) /** @type {Effect} */ From e9daf5d3fa526e44990169adf7b3030a98ad2f41 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:06:18 +0000 Subject: [PATCH 002/370] Address review: avoid map mutation in DOM stand-in, add changelog entry removeAttribute now replaces the stored attribute map instead of calling Map#delete on it in place, matching setAttribute and the project's no-in-place-mutation convention. Also adds the required changelog/unreleased/1734.md entry for the externally visible idle-by-default behavior. --- changelog/unreleased/1734.md | 5 +++++ fjs/emergent_testing/browser/proof.mjs | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 changelog/unreleased/1734.md diff --git a/changelog/unreleased/1734.md b/changelog/unreleased/1734.md new file mode 100644 index 000000000..878f1d509 --- /dev/null +++ b/changelog/unreleased/1734.md @@ -0,0 +1,5 @@ +- `website`/`emergent_testing/browser`: the generated browser test page starts + idle instead of auto-running on load, drops the `run` query parameter, and + labels its control `Run` instead of `Run again`; the control is now a + genuinely disabled control (not a click-ignoring one) while a suite is + loading or running, and reactivates on every terminal state diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index eb82bd93c..c17b4b7a1 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -12,7 +12,7 @@ import { runInNewContext } from 'node:vm' import { assert, assertEq, assertNotNullish, assertStructurallySame } from '../../asserts/module.f.mjs' import { renderBrowserReport, runBrowserProofs, startBrowserTests, startBrowserTestSources } from '../browser.mjs' -/** @typedef {{ readonly tag: string, readonly attributes: Map, readonly ownerDocument: _Document, textContent: string, children: readonly _Element[], readonly setAttribute: (name: string, value: string) => void, readonly removeAttribute: (name: string) => void, readonly querySelector: (selector: string) => _Element | null, readonly replaceChildren: (...nodes: readonly _Element[]) => void, readonly append: (node: _Element) => void }} _Element */ +/** @typedef {{ readonly tag: string, attributes: ReadonlyMap, readonly ownerDocument: _Document, textContent: string, children: readonly _Element[], readonly setAttribute: (name: string, value: string) => void, readonly removeAttribute: (name: string) => void, readonly querySelector: (selector: string) => _Element | null, readonly replaceChildren: (...nodes: readonly _Element[]) => void, readonly append: (node: _Element) => void }} _Element */ /** @typedef {{ defaultView: _View | null, readonly createElement: (tag: string) => _Element }} _Document */ /** @typedef {{ events: readonly CustomEvent[], readonly dispatchEvent: (event: Event) => boolean, fjsBrowserTestReport?: Promise }} _View */ @@ -35,9 +35,11 @@ const element = (document, tag, attributes, states) => { children: [], setAttribute: (name, value) => { if (name === 'data-state') { states.push(value) } - self.attributes.set(name, value) + self.attributes = new Map([...self.attributes, [name, value]]) + }, + removeAttribute: name => { + self.attributes = new Map([...self.attributes].filter(([key]) => key !== name)) }, - removeAttribute: name => { self.attributes.delete(name) }, // The runner only ever queries an attribute selector of `[name]` form. querySelector: selector => self.children.reduce( (/** @type {_Element | null} */ acc, child) => From d08d061a4a39aeddb9bcc06a2ca38b7fbf2b8ed9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:15:33 +0000 Subject: [PATCH 003/370] Update browser-testing plan for explicit-start supersession browser-test-controls.md now removes auto-start entirely, which contradicted browser-testing.md's auto-start-via-query-parameter requirement and its claim that index.html itself starts the runner. Both spots now point at the explicit-start plan instead. --- fjs/emergent_testing/todo/browser-testing.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index cdec6dade..d81dc562b 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -52,7 +52,10 @@ eventual isolated browser-test application root └── authored or copied .f.mjs / .mjs modules ``` -`index.html` starts the runner. The website integration currently loads the +`index.html` hosts the runner, idle until an explicit `Run` click or +controller call starts it — see +[Explicit browser test controls](browser-test-controls.md), which supersedes +auto-start below. The website integration currently loads the generated list of proof sources with native `import()` from the repository working tree; this is not the isolated application root described by this section. The eventual application exposes HTML and JavaScript only — it does @@ -98,10 +101,12 @@ preparation, loopback static serving, URL construction, report validation, timeout and infrastructure-error classification, and conversion of the report into a generic pass/fail result. -- **HTML page**: run/re-run UI with loading, running, passed, failed, and - infrastructure-error states; failed test paths with messages and stacks; - module-loading failures distinguished from proof failures; auto-start via a - query parameter. The FunctionalScript website hosts the same application and +- **HTML page**: idle, loading, running, passed, failed, and + infrastructure-error states, starting only on an explicit `Run` click or + controller call — no auto-start via a query parameter, per + [Explicit browser test controls](browser-test-controls.md); failed test + paths with messages and stacks; module-loading failures distinguished from + proof failures. The FunctionalScript website hosts the same application and report contract — no website-only implementation. - **`fjs browser-test`** (`build` / `serve` / `run --browser=...`): no Playwright dependency; starts a loopback server, opens or launches an From 8f531bad15d5bba3905be69e92d1deb636d9dacc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:21:20 +0000 Subject: [PATCH 004/370] Mark the changelog entry as breaking Removing the auto-start-on-load behavior and the run query parameter is an externally visible incompatibility for any controller or link that relied on either, so the entry needs the BREAKING CHANGES prefix to force at least a minor bump. --- changelog/unreleased/1734.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/changelog/unreleased/1734.md b/changelog/unreleased/1734.md index 878f1d509..ab7840e3e 100644 --- a/changelog/unreleased/1734.md +++ b/changelog/unreleased/1734.md @@ -1,5 +1,7 @@ -- `website`/`emergent_testing/browser`: the generated browser test page starts - idle instead of auto-running on load, drops the `run` query parameter, and - labels its control `Run` instead of `Run again`; the control is now a - genuinely disabled control (not a click-ignoring one) while a suite is - loading or running, and reactivates on every terminal state +- **BREAKING CHANGES:** `website`/`emergent_testing/browser`: the generated + browser test page no longer auto-runs on load and drops the `run` query + parameter — a controller or link that relied on either now finds the suite + idle instead. It starts only on an explicit `Run` click or controller call, + and the control is renamed from `Run again` to `Run` and is now a genuinely + disabled control (not a click-ignoring one) while a suite is loading or + running, reactivating on every terminal state From e4fc1c3a0ddddb423a323697dd5aab53b7d6cba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:44:25 +0000 Subject: [PATCH 005/370] Add proof coverage for a root without a Run control page() always inserts the run button now, so the setState branch that finds no [data-test-run] was unexercised. Build a bare root without one directly and run a suite against it to confirm setState tolerates its absence, as an embedding without the control is meant to. --- fjs/emergent_testing/browser/proof.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index c17b4b7a1..4e0a472c0 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -336,6 +336,25 @@ export const proof = { assertStructurallySame([...p.states], ['loading', 'infrastructure-error']) assertEq(p.view.events.length, 1) }, + runControlAbsentButtonIsIgnored: async () => { + // An embedding root with no `[data-test-run]` control is still + // supported: `setState` finds nothing to toggle and moves on rather + // than throwing. + /** @type {string[]} */ + const states = [] + /** @type {_Document} */ + const document = { + defaultView: null, + createElement: tag => element(document, tag, [], states), + } + const root = element(document, 'main', ['data-browser-tests'], states) + root.replaceChildren( + element(document, 'p', ['data-test-summary'], states), + element(document, 'ol', ['data-test-results'], states)) + const report = await startBrowserTests(/** @type {Element} */ (/** @type {unknown} */ (root)), + [['m', { ok: () => undefined }]]) + assertEq(report.status, 'passed') + }, runControlIdle: () => { // The page starts idle: no run is underway, so `Run` is active and // carries no `disabled` attribute at all — not merely an unchecked one. From 2e793769974bab2808d0719b017fb20a5282ec4c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:48:12 +0000 Subject: [PATCH 006/370] Shorten the changelog entry Trims it to about three wrapped lines per changelog/README.md, moving the controller/link migration detail to the PR description where it already lives. --- changelog/unreleased/1734.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/changelog/unreleased/1734.md b/changelog/unreleased/1734.md index ab7840e3e..938772b06 100644 --- a/changelog/unreleased/1734.md +++ b/changelog/unreleased/1734.md @@ -1,7 +1,4 @@ - **BREAKING CHANGES:** `website`/`emergent_testing/browser`: the generated - browser test page no longer auto-runs on load and drops the `run` query - parameter — a controller or link that relied on either now finds the suite - idle instead. It starts only on an explicit `Run` click or controller call, - and the control is renamed from `Run again` to `Run` and is now a genuinely - disabled control (not a click-ignoring one) while a suite is loading or - running, reactivating on every terminal state + browser test page starts idle instead of auto-running on load, and drops the + `run` query parameter; its control is renamed `Run` and stays genuinely + disabled while a suite is loading or running From 5506b5dcb6e7cd822ffa1f3958f4a746bb3070a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:02:54 +0000 Subject: [PATCH 007/370] Prove the generated page's idle default, and drop a tautological proof website/proof.f.mjs::run now asserts the generated HTML carries data-state="idle" and the Run label (never "Run again"), and that the generated entry module wires the click handler and stops rather than auto-starting via a query parameter. Reverting the website half of this change now fails that proof, where it previously survived untouched. runControlIdle in browser/proof.mjs never called production code: it built its own DOM stand-in and asserted properties of that stand-in, so it passed under every mutation of browser.mjs and module.f.mjs. Removed; the idle-default claim is now proven by website/proof.f.mjs, and the disabled/enabled state-transition claims are already mutation-tested by the other runControl* proofs. --- fjs/emergent_testing/browser/proof.mjs | 7 ------- fjs/website/proof.f.mjs | 15 ++++++++++++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 4e0a472c0..bf058709b 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -355,13 +355,6 @@ export const proof = { [['m', { ok: () => undefined }]]) assertEq(report.status, 'passed') }, - runControlIdle: () => { - // The page starts idle: no run is underway, so `Run` is active and - // carries no `disabled` attribute at all — not merely an unchecked one. - const p = page() - assertEq(p.states.length, 0) - assertEq(p.runButton.attributes.has('disabled'), false) - }, runControlDisabledWhileActive: async () => { // `Run` must be passive — genuinely disabled, not just click-ignoring — // for the whole span between a click and the next terminal state: diff --git a/fjs/website/proof.f.mjs b/fjs/website/proof.f.mjs index ccb7f5f92..888a79ff0 100644 --- a/fjs/website/proof.f.mjs +++ b/fjs/website/proof.f.mjs @@ -20,11 +20,24 @@ export const proof = { const [generated, result] = virtual(state)(main()) assertEq(exitCode(result), 0) const page = assertNotNullish(generated.root['index.html'], 'expected generated HTML') - assertNotNullish(generated.root['_browser-test-entry.mjs'], 'expected generated entry module') + const entryFile = assertNotNullish(generated.root['_browser-test-entry.mjs'], + 'expected generated entry module') assert(Array.isArray(page), 'expected the generated HTML to be a file') + assert(Array.isArray(entryFile), 'expected the generated entry module to be a file') const source = page.map(value => utf8ToString(/** @type {Vec} */ (value))).join('') + const entry = entryFile.map(value => utf8ToString(/** @type {Vec} */ (value))).join('') assert(source.includes('

Emergent Testing in the Browser

')) assert(source.includes('emergent-testing-in-javascript-e44760d71688')) assert(!source.includes('?sk=')) + // The page starts idle, not mid-run, and its only control is the + // renamed `Run` — never the old `Run again` label. + assert(source.includes('data-state="idle"'), source) + assert(source.includes('>Run'), source) + assert(!source.includes('Run again'), source) + // The entry module wires the click handler and stops: it must not + // call `start()` on its own, whether unconditionally or behind a + // `run` query parameter. + assert(!entry.includes('searchParams'), entry) + assert(entry.trim().endsWith("runButton.addEventListener('click', start)"), entry) }, } From 32655f5a158034e1c2ce19ef51795cf705a18020 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:08:52 +0000 Subject: [PATCH 008/370] Update the summary synchronously when a run starts startBrowserTestSources set the loading state and disabled the Run button immediately, but the summary text only changed once the first module import settled, so the page kept showing its idle text throughout loading if a click's imports were slow or never resolved. Write a loading summary synchronously, before any import can settle. --- fjs/emergent_testing/browser.mjs | 5 +++++ fjs/emergent_testing/browser/proof.mjs | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 2683bfae6..3d280f3bf 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -334,6 +334,11 @@ export const startBrowserTestSources = (root, sources, importer) => { setState(root, 'loading') let loaded = 0 const summary = root.querySelector('[data-test-summary]') + // Set synchronously, before any import settles: otherwise the page keeps + // showing its idle text throughout loading — indefinitely, if a module + // import never settles — even though the state and control already + // changed. + if (summary !== null) { summary.textContent = `Loading 0/${sources.length}` } // The importer is supplied by the page, so obtaining the promise is itself // a failure point: a synchronous throw becomes a rejection here and is // reported as a loader failure, rather than escaping past a `loading` state diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index bf058709b..c4af06c77 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -309,6 +309,14 @@ export const proof = { assertStructurallySame([...p.states], ['loading', 'running', 'passed']) assertEq(await p.view.fjsBrowserTestReport, report) }, + sourcesLoadingSummaryIsSynchronous: () => { + // The summary must not keep showing idle text through loading: it is + // replaced the instant a run starts, before any import has had a + // chance to settle — even one that never does. + const p = page() + void startBrowserTestSources(p.root, ['a.mjs', 'b.mjs'], () => new Promise(() => undefined)) + assertEq(p.summary.textContent, 'Loading 0/2') + }, sourcesProgress: async () => { const p = page() /** @type {(module: { readonly proof?: unknown }) => void} */ From 8becd3f62c9cdaaefbdc5bb3866a0555b3ed0e6c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:14:17 +0000 Subject: [PATCH 009/370] Update browser-test-controls.md status and Problem for what remains Status moves to wip now that the Run/idle half has landed, and the Problem section describes only the outstanding Cancel/cancellation work instead of a page that no longer auto-starts or says Run again. --- .../todo/browser-test-controls.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/fjs/emergent_testing/todo/browser-test-controls.md b/fjs/emergent_testing/todo/browser-test-controls.md index 32bce318f..77c391782 100644 --- a/fjs/emergent_testing/todo/browser-test-controls.md +++ b/fjs/emergent_testing/todo/browser-test-controls.md @@ -1,18 +1,16 @@ ## Add explicit browser test controls **Priority:** P3 -**Status:** open +**Status:** wip ### Problem -The generated browser-test page starts its suite as soon as the entry module -loads and labels its only button `Run again`. That makes an expensive full run -surprising, gives a user no idle state in which to inspect the page, and offers -no way to stop a run that is no longer useful. - -The controls also do not express the runner state clearly. A run action should -be available only while no suite is active, while cancellation should be -available only while a suite is active. +The generated browser-test page now starts idle, waits for an explicit `Run` +click or controller call, and keeps that control genuinely disabled — not +merely click-ignoring — while a suite is loading or running. What remains is +the other half of the proposal below: there is still no way to stop a run +that is no longer useful, and no `Cancel` control expressing that a suite is +active. ### Proposal From a5cbc26ab35a9e93cfde2212df7e37cecb63c3c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:20:45 +0000 Subject: [PATCH 010/370] Fix remaining fjs/types/rtti references after the rtti move Merges the rtti-rename branch (git mv fjs/types/rtti -> fjs/rtti, with its imports re-anchored) and updates everything the move left behind: - Doc-comment references to the old path in code outside rtti/. - Inbound links/prose across the repo that named types/rtti, dropping the types/ segment while keeping their ../ count (the one exception, fjs/types/todo/66d-ts-printer-tuple-readonly-fold.md, gains a ../ since it stays inside fjs/types/ itself). - The moved subtree's own outward references (to edag, media, spec, AGENTS.md, tsconfig.json, etc.), each losing one ../ to match rtti's new depth, and its self-referential fjs/types/rtti/... paths. - Two inward references inside the moved subtree that were missed (phantom/types.ts, object/module.f.mjs) which needed to gain a ../ and a types/ segment. - fjs/todo/group-fs-subdirectories-by-concern.md's "Later candidates" bullet, turned into a done Tasks entry. - fjs/rtti/README.md, which now records the membership argument (peer of djs, not a member of types/) so it outlives the deleted TODO. Deletes fjs/todo/move-rtti-out-of-types.md, whose plan is now carried out. npx tsc and the full test suite (3465 tests) pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EJuNsKf9jpgULV67jFo2sy --- fjs/AGENTS.md | 4 +- fjs/bnf/todo/207.md | 18 +- fjs/bnf/todo/recognizer-backend.md | 4 +- fjs/bnf/todo/rule-visitor.md | 4 +- fjs/djs/todo/197.md | 10 +- fjs/edag/README.md | 8 +- fjs/edag/amnesia/README.md | 2 +- fjs/edag/module.f.mjs | 6 +- fjs/edag/proof.f.mjs | 2 +- fjs/edag/types.ts | 2 +- .../todo/65y-proof-asserteq-adoption.md | 2 +- .../todo/668-emergent-testing-proof-type.md | 2 +- fjs/mcp/README.md | 2 +- fjs/mcp/evo/README.md | 2 +- fjs/media/json/module.f.mjs | 2 +- fjs/media/json/schema/module.f.mjs | 2 +- fjs/media/json/todo/remove-native-json.md | 2 +- fjs/media/json/todo/rtti-parse.md | 10 +- fjs/media/revision/module.f.mjs | 2 +- fjs/nanvm/README.md | 2 +- fjs/nanvm/todo/reuse-edag-operators.md | 2 +- fjs/nanvm/types.ts | 2 +- fjs/protocol/mcp/README.md | 2 +- fjs/protocol/mcp/todo/README.md | 4 +- .../mcp/todo/toolentry-hoist-invariants.md | 6 +- fjs/rtti/README.md | 21 +- fjs/rtti/data/README.md | 4 +- fjs/rtti/host.proof.mjs | 4 +- fjs/rtti/todo/668-rtti-function-types.md | 12 +- fjs/rtti/todo/checked-const-pin.md | 10 +- .../data-validate-admits-non-djs-values.md | 8 +- fjs/rtti/todo/excluded-string-values.md | 10 +- fjs/rtti/todo/export-node-accessors.md | 12 +- fjs/rtti/todo/identity-aware-parse.md | 10 +- fjs/rtti/todo/kindset-eliminator.md | 12 +- fjs/rtti/todo/option-as-omission.md | 30 ++- .../todo/parse-omits-undefined-members.md | 2 +- fjs/rtti/todo/prefix-then-rest-tuple.md | 2 +- fjs/rtti/todo/proof-shared-asserts.md | 4 +- fjs/rtti/todo/shared-helper-reuse.md | 12 +- fjs/rtti/ts/module.f.mjs | 2 +- fjs/rtti/ts/types.ts | 2 +- .../group-fs-subdirectories-by-concern.md | 5 +- fjs/todo/move-rtti-out-of-types.md | 203 ------------------ fjs/types/phantom/types.ts | 4 +- .../66d-ts-printer-tuple-readonly-fold.md | 2 +- spec/todo/3360-type-annotations.md | 20 +- spec/todo/3370-type-inference.md | 6 +- todo/README.md | 2 +- todo/edag-spec.md | 6 +- todo/flow.md | 4 +- todo/inline-type-casts.md | 52 ++--- todo/migrate-typescript-to-mjs.md | 8 +- todo/new-pl.md | 2 +- todo/plan/capl.md | 2 +- todo/retired-issue-identifiers.md | 2 +- todo/rtti-type-system.md | 90 ++++---- todo/tsconfig-strict-flags.md | 2 +- todo/types-for-fs.md | 2 +- 59 files changed, 242 insertions(+), 431 deletions(-) delete mode 100644 fjs/todo/move-rtti-out-of-types.md diff --git a/fjs/AGENTS.md b/fjs/AGENTS.md index 6f1d7e71c..eb11b882b 100644 --- a/fjs/AGENTS.md +++ b/fjs/AGENTS.md @@ -402,8 +402,8 @@ validate({ a: 42 }) // the same, with `` A cast there is the absence of a modifier on the callee, not a fact about the value — and it has to be repeated at every call, where the modifier is written -once. `types/rtti` (`or`, `option`, `array`, `record`), `types/rtti/validate`, -`types/rtti/parse`, `types/result` (`ok`, `error`), `protocol/mcp`'s +once. `rtti` (`or`, `option`, `array`, `record`), `rtti/validate`, +`rtti/parse`, `types/result` (`ok`, `error`), `protocol/mcp`'s `toolEntry`, and `bnf`'s `option` already carry it; a new schema- or literal-taking export should too. diff --git a/fjs/bnf/todo/207.md b/fjs/bnf/todo/207.md index f1003c7b2..372c976e5 100644 --- a/fjs/bnf/todo/207.md +++ b/fjs/bnf/todo/207.md @@ -35,7 +35,7 @@ Two properties are required: produce, and its output must match what the parent expects. Doing this in plain TypeScript turns out to be impractical for real (cyclic) grammars (§4); the fallback is to declare each action's input/output with an RTTI - schema (`fjs/types/rtti`) and check the boundary at runtime (§5). + schema (`fjs/rtti`) and check the boundary at runtime (§5). This document is a design only. No implementation is proposed here beyond the hypotheses actually tested in §4. @@ -391,7 +391,7 @@ collapses for any realistic cyclic grammar. We need a runtime contract instead. ### 5. RTTI as the type-checking contract -RTTI (`fjs/types/rtti`) already gives us exactly the missing piece: a runtime +RTTI (`fjs/rtti`) already gives us exactly the missing piece: a runtime schema (`Type`) that **also** projects to a static TypeScript type via `Ts`. The design uses it on both sides of every action: @@ -415,7 +415,7 @@ This splits the problem cleanly: safely `Ts`. But most of this check can be lifted to a **one-time check at grammar instantiation** (§5.3) instead of running per parsed node. -`fjs/types/rtti/parse` (`parse(schema)(value)` — builds a fresh value holding +`fjs/rtti/parse` (`parse(schema)(value)` — builds a fresh value holding exactly the declared members) is directly usable, and is the reader to use: it already normalizes containers and drops undeclared slots. @@ -477,7 +477,7 @@ pipeline sound once instead of re-checking each value. which `validate`/`parse` (value-vs-schema) do not give. This is exactly the `equal`/`subset` algebra of the function-free data form, and it **has since shipped**: `equal` and `subset` are exported from -[`fjs/types/rtti/data/module.f.mjs`](../../types/rtti/data/module.f.mjs). This +[`fjs/rtti/data/module.f.mjs`](../../rtti/data/module.f.mjs). This section is no longer gated on it; what is open is whether the shipped predicate fits, below. @@ -489,7 +489,7 @@ sharing the grammar walk with `toData` — is untouched and still an implementation concern. **Open question the original text did not anticipate.** The shipped `subset` is -*sound but deliberately incomplete* (`fjs/types/rtti/data/README.md`): it never +*sound but deliberately incomplete* (`fjs/rtti/data/README.md`): it never answers `true` for a non-inclusion, but it may answer `false` for an inclusion that holds only by distributing a union across positions, or whose left side is a non-syntactic empty set. "The predicate exists" is therefore not quite "every @@ -533,7 +533,7 @@ The two wrinkles as originally written: per-node `parse` used to strip. This is not a top-level property. `parse` recurses — - `fjs/types/rtti/parse/module.f.mjs` states that "every element/value is + `fjs/rtti/parse/module.f.mjs` states that "every element/value is itself parsed, so a fresh container is" built, and that a tuple result "has the schema's length" — so `in = array({ a: number })` strips a stray `b` from *every element* even though the outer schema is an array. The question @@ -709,10 +709,10 @@ a JSON action set exist as the first real consumer. is the mechanism for both its "how does meta propagate up a reduction" and "lower-layer values (lexemes) carried past the upper grammar" questions; the reduction algebra (§3.3) is the shared evaluation contract across layers. -- i172 (retired; shipped as [`fjs/types/rtti/validate/`](../../types/rtti/validate/module.f.mjs) - and [`fjs/types/rtti/parse/`](../../types/rtti/parse/module.f.mjs)) — `validate`/`parse` +- i172 (retired; shipped as [`fjs/rtti/validate/`](../../rtti/validate/module.f.mjs) + and [`fjs/rtti/parse/`](../../rtti/parse/module.f.mjs)) — `validate`/`parse` skeleton and `ValidationError`; the runtime checker this design leans on. -- i143 (retired; shipped as [`fjs/types/rtti/data/`](../../types/rtti/data/module.f.mjs)) — +- i143 (retired; shipped as [`fjs/rtti/data/`](../../rtti/data/module.f.mjs)) — the serializable RTTI data form and its `equal`/`subset` algebra, both exported; the predicate the instantiation-time boundary check (§5.3) depends on — no longer a blocker — and relevant if schemas are auto-derived from the diff --git a/fjs/bnf/todo/recognizer-backend.md b/fjs/bnf/todo/recognizer-backend.md index 7de35cff3..8923a3c92 100644 --- a/fjs/bnf/todo/recognizer-backend.md +++ b/fjs/bnf/todo/recognizer-backend.md @@ -190,7 +190,7 @@ host language instead. The same move recurs across the codebase: - `fjs/media/html` — markup as nested element values (`['a', { href }, 'Example']`), not JSX; serialized by an emitter function; -- `fjs/types/rtti` — a type is a schema *value* from which `ts/` derives the +- `fjs/rtti` — a type is a schema *value* from which `ts/` derives the TypeScript type, `validate/` a validator, and `parse/` a deserializer (the cas/mcp tool args already use one rtti struct for both `inputSchema` and `validate`); @@ -310,7 +310,7 @@ Bigger automata are built from BNF pieces in two complementary ways: - [layered-parser](./layered-parser.md) — same "one BNF engine, multiple layers" instinct; the DFA backend is the scanner tier - [parser-structure](./parser-structure.md) — the AST-producing backend -- `fjs/types/rtti` — the type-level sibling of this strategy: types as schema +- `fjs/rtti` — the type-level sibling of this strategy: types as schema values, many artifacts (TS type, validator, parser) derived by function - `fjs/media/html` — the markup-level sibling: an embedded DSL of nested element values, not an external syntax (JSX) diff --git a/fjs/bnf/todo/rule-visitor.md b/fjs/bnf/todo/rule-visitor.md index 3a44e0d84..47618cef2 100644 --- a/fjs/bnf/todo/rule-visitor.md +++ b/fjs/bnf/todo/rule-visitor.md @@ -30,7 +30,7 @@ coming. After the alphabet split settles the generic `Rule` union, add a visitor in `fjs/bnf/data/module.f.mjs` (the module that owns the type), mirroring the proven -`visit` pattern in `fjs/types/rtti/common`. +`visit` pattern in `fjs/rtti/common`. Conceptually the visitor exposes the semantic rule cases: @@ -91,4 +91,4 @@ scheme. Each call site keeps its own recursion/accumulator structure. both backends now read from instead of re-deriving. It is still the natural consumer of this visitor: `emptyTagMap` walks the rule tree itself, so it is one of the traversals a `Rule` visitor would absorb. -- `fjs/types/rtti/common/module.f.mjs` — existing `visit` precedent. +- `fjs/rtti/common/module.f.mjs` — existing `visit` precedent. diff --git a/fjs/djs/todo/197.md b/fjs/djs/todo/197.md index bfdf86bc7..c32df1074 100644 --- a/fjs/djs/todo/197.md +++ b/fjs/djs/todo/197.md @@ -68,7 +68,7 @@ The five walkers vary only in: the recursion when a ref already exists. This is exactly the variation that -`fjs/types/rtti/common/module.f.mjs:visit` was built for: it parametrises +`fjs/rtti/common/module.f.mjs:visit` was built for: it parametrises schema traversal by a `Visitor` with one handler per variant, and both `validate` and `parse` plug in different visitors over the same ADT. The same factoring applies here. @@ -179,15 +179,15 @@ sharing the four common leaves through a base visitor. - [i157 §2](./157.md) — the serializer walker factoring. This issue is its natural follow-up: same idea, two additional call sites. -- i172 (retired; shipped as [`fjs/types/rtti/validate/`](../../types/rtti/validate/module.f.mjs) - and [`fjs/types/rtti/parse/`](../../types/rtti/parse/module.f.mjs)) — a similar +- i172 (retired; shipped as [`fjs/rtti/validate/`](../../rtti/validate/module.f.mjs) + and [`fjs/rtti/parse/`](../../rtti/parse/module.f.mjs)) — a similar "merge parallel container factories" idea on the RTTI side. The pattern here is the same shape; the conclusions about when to defer apply equally. -- i143 (retired; shipped as [`fjs/types/rtti/data/`](../../types/rtti/data/module.f.mjs)) — +- i143 (retired; shipped as [`fjs/rtti/data/`](../../rtti/data/module.f.mjs)) — the RTTI data form, which supplied the third consumer this issue was waiting on: `data/`, `validate/` and `parse/` all take the shared traversal - from [`fjs/types/rtti/common/`](../../types/rtti/common/module.f.mjs) + from [`fjs/rtti/common/`](../../rtti/common/module.f.mjs) (`eachEntry`, `visit`), so "merge parallel factories behind one traversal" is proven on the RTTI side and i172 shipped. It does not settle the DJS side, whose reasons to defer are unrelated to that count and unchanged: the diff --git a/fjs/edag/README.md b/fjs/edag/README.md index 415338e90..bfa70dafc 100644 --- a/fjs/edag/README.md +++ b/fjs/edag/README.md @@ -26,14 +26,14 @@ flat array of steps that admitted four families of duplicates, and the uniqueness is structural — the wrong shapes are unspellable rather than rejected by a validation pass a producer has to remember to run. -The shape is defined once, as an [RTTI](../types/rtti/) schema in +The shape is defined once, as an [RTTI](../rtti/) schema in [module.f.mjs](module.f.mjs) — the specification of record, checkable at runtime with `validate(exp)` (shape only — see Caveats). [types.ts](types.ts) carries the same shape at the type level, pinned against the schema with `Assert>` so the two cannot drift. Every tuple in the schema is closed — none of them says `open`, which is what an rtti tuple needs to admit more than it declares — so the static tuples and the runtime ones agree exactly, an exact-length -[TupleTs](../types/rtti/ts/types.ts) rendering over an exact-length set. +[TupleTs](../rtti/ts/types.ts) rendering over an exact-length set. [proof.f.mjs](proof.f.mjs) pins what the schema accepts and rejects, node kind by node kind — validation behavior, not execution semantics — with `comma` excepted until its placeholder shape settles. Its `ownJs` and @@ -317,7 +317,7 @@ need it. shared subgraph once per incoming edge (exponential in depth) and overflows the stack on a cycle instead of rejecting it; `parse` rebuilds every container, so sharing is lost — - [identity-aware-parse.md](../types/rtti/todo/identity-aware-parse.md). + [identity-aware-parse.md](../rtti/todo/identity-aware-parse.md). So `validate` is shape validation, not complete EDAG validation: identity-dependent canonicality — acyclicity, and the rule that an operation-node identity may be shared only within one function's scope, @@ -343,7 +343,7 @@ need it. Object spread reads those properties *through* getters, unlike `own`, which reads the descriptor's value and never calls one. - `index` does not yet exclude `constructor`/`__proto__` — - [excluded-string-values.md](../types/rtti/todo/excluded-string-values.md). + [excluded-string-values.md](../rtti/todo/excluded-string-values.md). ## Design diff --git a/fjs/edag/amnesia/README.md b/fjs/edag/amnesia/README.md index d61ed4f65..1ecd650ca 100644 --- a/fjs/edag/amnesia/README.md +++ b/fjs/edag/amnesia/README.md @@ -44,7 +44,7 @@ compounds in the same way. A chain of 22 shared additions, | here | 8,388,607 (~0.3 s) | Exponential in depth, and the leaf alone is visited 4,194,304 times. -[`validate`](../../types/rtti/validate/module.f.mjs) has the same flaw for the +[`validate`](../../rtti/validate/module.f.mjs) has the same flaw for the same reason, which is where the word for it comes from. The models that do preserve identity, and what each is for, are in [execution-models.md](../execution-models.md). diff --git a/fjs/edag/module.f.mjs b/fjs/edag/module.f.mjs index 649dee82f..f742bb826 100644 --- a/fjs/edag/module.f.mjs +++ b/fjs/edag/module.f.mjs @@ -44,7 +44,7 @@ import { /** * Every tuple here is closed — the members it declares and nothing else, which * is what a bare `Tuple` says ("Structs and tuples are closed" in - * `../types/rtti/README.md`). That is load-bearing rather than incidental: the + * `../rtti/README.md`). That is load-bearing rather than incidental: the * chain grammar below claims each JS chain has exactly one spelling, and an * `open` tuple would let any node carry a trailing element nothing reads, * splitting one function into unboundedly many graphs. So do **not** wrap any @@ -53,7 +53,7 @@ import { * missing position. * * Do not call `parse(exp)` or rely on `validate(exp)` rejecting cycles - * without reading `../types/rtti/todo/identity-aware-parse.md` first — + * without reading `../rtti/todo/identity-aware-parse.md` first — * neither is identity-aware, and that TODO covers why and what's missing. */ @@ -232,7 +232,7 @@ export const numberCast = /** @type {const} */ (['Number', exp]) * name. * * Does not exclude `'constructor'`/`'__proto__'` — TODO, see - * `../types/rtti/todo/excluded-string-values.md`. + * `../rtti/todo/excluded-string-values.md`. */ export const index = or(numberCast, string, number) diff --git a/fjs/edag/proof.f.mjs b/fjs/edag/proof.f.mjs index db0b51756..90a92faa1 100644 --- a/fjs/edag/proof.f.mjs +++ b/fjs/edag/proof.f.mjs @@ -29,7 +29,7 @@ const assertOk = ([k]) => { assertEq(k, 'ok', 'expected ok') } * `exp` is a top-level `or` trying every node kind in turn, so when a value * matches none of them the reported failure is always the root (`path: []`, * `message: 'no match'`) — there is no single branch whose deeper path is - * "the" failure. Same rule as `../types/rtti/validate/proof.f.mjs`'s `orRoot`. + * "the" failure. Same rule as `../rtti/validate/proof.f.mjs`'s `orRoot`. * The three lambda schemas are `or`s too, so their failures report the same * way. * @type {(r: readonly [string, unknown]) => void} diff --git a/fjs/edag/types.ts b/fjs/edag/types.ts index 2c0beeb54..679e8072c 100644 --- a/fjs/edag/types.ts +++ b/fjs/edag/types.ts @@ -5,7 +5,7 @@ * added — each pinned against its rtti schema in the sibling module with * `Assert>`. Every tuple here is closed on both sides: * none of the schemas says `open`, so these types are exact rather than the - * approximation `TupleTs` in `../types/rtti/ts/types.ts` describes. + * approximation `TupleTs` in `../rtti/ts/types.ts` describes. */ // exp diff --git a/fjs/emergent_testing/todo/65y-proof-asserteq-adoption.md b/fjs/emergent_testing/todo/65y-proof-asserteq-adoption.md index 8c95d400b..8c4c62dc6 100644 --- a/fjs/emergent_testing/todo/65y-proof-asserteq-adoption.md +++ b/fjs/emergent_testing/todo/65y-proof-asserteq-adoption.md @@ -34,7 +34,7 @@ Counts in the current tree (re-verified 2026-08-14): `fjs/types/nominal/proof.f.mjs`, `fjs/types/object/structurally_same/proof.f.mjs`, `fjs/types/range/proof.f.mjs`, `fjs/types/range_set/proof.f.mjs`, - `fjs/types/rtti/proof.f.mjs`, `todo/proof.f.mjs`. + `fjs/rtti/proof.f.mjs`, `todo/proof.f.mjs`. - A number of files already using `assertEq` still carry leftover manual `if (...) { throw ... }` sites alongside it (the 494 count above is not confined to the 9 holdout files) — full adoption within diff --git a/fjs/emergent_testing/todo/668-emergent-testing-proof-type.md b/fjs/emergent_testing/todo/668-emergent-testing-proof-type.md index f328cd818..3da11674b 100644 --- a/fjs/emergent_testing/todo/668-emergent-testing-proof-type.md +++ b/fjs/emergent_testing/todo/668-emergent-testing-proof-type.md @@ -47,7 +47,7 @@ function values before it can model the full proof tree. - [i65Z-tf-test-tree-walker](./65z-tf-test-tree-walker.md) — planned shared proof-tree traversal. -- [i668-rtti-function-types](../../types/rtti/todo/668-rtti-function-types.md) — extern RTTI for +- [i668-rtti-function-types](../../rtti/todo/668-rtti-function-types.md) — extern RTTI for function-valued proof leaves. - [i665-proof-property-tests](./665-proof-property-tests.md) — future proof shape extension. diff --git a/fjs/mcp/README.md b/fjs/mcp/README.md index 6966de373..dcc9ecbdd 100644 --- a/fjs/mcp/README.md +++ b/fjs/mcp/README.md @@ -67,7 +67,7 @@ covers `cas_add`/`cas_get`/`cas_list`. Each tool's argument schema is an rtti struct declared once and used twice: [`toJsonSchema`](../media/json/schema/module.f.mjs) derives the `inputSchema` -advertised in `tools/list`, and [`parse`](../types/rtti/parse/module.f.mjs) +advertised in `tools/list`, and [`parse`](../rtti/parse/module.f.mjs) decodes the `arguments` object in `tools/call`. There is no drift between what we advertise and what we accept. diff --git a/fjs/mcp/evo/README.md b/fjs/mcp/evo/README.md index e06603ce4..f593226c2 100644 --- a/fjs/mcp/evo/README.md +++ b/fjs/mcp/evo/README.md @@ -67,7 +67,7 @@ cycle. Each tool's argument schema is an rtti struct declared once and used twice: [`toJsonSchema`](../../media/json/schema/module.f.mjs) derives the `inputSchema` advertised in `tools/list`, and -[`parse`](../../types/rtti/parse/module.f.mjs) decodes the +[`parse`](../../rtti/parse/module.f.mjs) decodes the `arguments` object in `tools/call` — the same pattern as [`fjs/mcp`](../). diff --git a/fjs/media/json/module.f.mjs b/fjs/media/json/module.f.mjs index 8d6607cb1..5f5ebceeb 100644 --- a/fjs/media/json/module.f.mjs +++ b/fjs/media/json/module.f.mjs @@ -99,7 +99,7 @@ const numberPolicy = token => ok(parseFloat(token.value)) * for them. * * The result is an untyped {@link Unknown}; narrow it to a domain type with an - * rtti schema (`fjs/types/rtti/parse`) rather than with an `as` cast. + * rtti schema (`fjs/rtti/parse`) rather than with an `as` cast. * * @type {(text: string) => Result} */ diff --git a/fjs/media/json/schema/module.f.mjs b/fjs/media/json/schema/module.f.mjs index 9ee7737cd..e7707b1ec 100644 --- a/fjs/media/json/schema/module.f.mjs +++ b/fjs/media/json/schema/module.f.mjs @@ -2,7 +2,7 @@ * Converts an rtti schema to a JSON Schema (draft 2020-12) object. * * {@link toJsonSchema} routes through the serializable RTTI data form - * (`fjs/types/rtti/data`): `thunk RTTI → toData → dataToJsonSchema`. The data + * (`fjs/rtti/data`): `thunk RTTI → toData → dataToJsonSchema`. The data * form is a finite graph, so recursive schemas — which the thunk graph * represents as self-referencing functions with no leaves — terminate: * every named rule is emitted exactly once under `$defs` and every graph diff --git a/fjs/media/json/todo/remove-native-json.md b/fjs/media/json/todo/remove-native-json.md index 92a1664cc..268b0c410 100644 --- a/fjs/media/json/todo/remove-native-json.md +++ b/fjs/media/json/todo/remove-native-json.md @@ -40,7 +40,7 @@ Three reasons to finish the job: | --- | --- | --- | --- | | **Leaf serializer** | 1 | `fjs/media/json/serializer/module.f.mjs` | FunctionalScript number formatting — blocks everything below | | Expected-output comparison | 73 | `fjs/bnf/ll1/proof.f.mjs` (27), `fjs/bnf/descent/proof.f.mjs` (22), `fjs/media/json/serializer/proof.f.mjs` (10), `fjs/djs/tokenizer/proof.f.mjs:886-921` (8), `fjs/bnf/data/proof.f.mjs` (4), `fjs/media/revision/proof.f.mjs:177`, `fjs/cas/evo/proof.f.mjs:68` | `stringify(identity)` | -| Assertion messages | 33 | `fjs/djs/tokenizer/proof.f.mjs` (31), `fjs/types/rtti/ts/proof.f.mjs:8,12` (2) | pass the value, or `fjs/djs`'s `stringify` | +| Assertion messages | 33 | `fjs/djs/tokenizer/proof.f.mjs` (31), `fjs/rtti/ts/proof.f.mjs:8,12` (2) | pass the value, or `fjs/djs`'s `stringify` | | Source-text quoting | 5 | `fjs/emergent_testing/module.f.mjs:282,303,318`, `fjs/types/ts/module.f.mjs:36,48` | `stringSerialize` — already designed in `66c-emit-literals-via-owner-modules.md` | | JSON line framing | 2 | `fjs/emergent_testing/proof.f.mjs:47`, `fjs/mcp/proof.f.mjs:128` | `stringify(identity)` | | Pretty-printed file output | 1 | `fjs/ci/module.f.mjs:83` | needs indentation support, which `serialize` does not have | diff --git a/fjs/media/json/todo/rtti-parse.md b/fjs/media/json/todo/rtti-parse.md index f25b36e57..a744e9e4c 100644 --- a/fjs/media/json/todo/rtti-parse.md +++ b/fjs/media/json/todo/rtti-parse.md @@ -34,7 +34,7 @@ available, but it is not required to exist or be exact for every valid JSON toke The shared JSON structural parse keeps the complete token available until the domain-specific numeric policy has run. -The existing `fjs/types/rtti/parse` should remain unchanged: it parses arbitrary +The existing `fjs/rtti/parse` should remain unchanged: it parses arbitrary runtime values and therefore correctly requires primitive runtime types to match the schema. JSON-specific numeric conversion is a separate adapter concern. @@ -240,20 +240,20 @@ semantically identical to the JSON-text parser for fractional-to-bigint checks. - [`fjs/media/json/tokenizer/module.f.mjs`](../tokenizer/module.f.mjs) — JSON token production must preserve the numeric lexeme before any unrepresentable derived numeric construction. -- [`fjs/types/rtti/parse`](../../../types/rtti/parse/module.f.mjs) — existing strict +- [`fjs/rtti/parse`](../../../rtti/parse/module.f.mjs) — existing strict runtime-value parser whose structural behavior should be reused where possible, not changed to add JSON-specific coercion. -- [`fjs/types/rtti/README.md`](../../../types/rtti/README.md) — the schema-form +- [`fjs/rtti/README.md`](../../../rtti/README.md) — the schema-form `validate` has been deleted, which makes this parser the answer for callers reading JSON text against a schema rather than a convenience. Structs and tuples are **closed** there, so the "drop extra struct fields/tuple elements where the current parser does" behavior this task inherits is what `open(c)` now buys rather than what a bare schema gives: a bare one *errors* on an undeclared member instead. -- [Open containers](../../../types/rtti/README.md#open-containers) — `open(c)` +- [Open containers](../../../rtti/README.md#open-containers) — `open(c)` and `rest(c, r)`, which have shipped. A stated `rest` holds an undeclared member to that rest without carrying it into what `parse` builds, so this parser needs that case too — alongside the closed default's rejection. -- [RTTI serializable data form](../../../types/rtti/data/README.md) +- [RTTI serializable data form](../../../rtti/data/README.md) — a future data-driven RTTI parser can support the same JSON numeric conversion policy. diff --git a/fjs/media/revision/module.f.mjs b/fjs/media/revision/module.f.mjs index 04e7e3a1e..5190721f6 100644 --- a/fjs/media/revision/module.f.mjs +++ b/fjs/media/revision/module.f.mjs @@ -56,7 +56,7 @@ export const hash = string * * Self-referential through {@link lockValue}, which is a module-level * constant rather than a union rebuilt inside the thunk: the rtti data form - * (`fjs/types/rtti/data`, which `toJsonSchema` routes through) closes + * (`fjs/rtti/data`, which `toJsonSchema` routes through) closes * reference cycles by *identity*, so a schema handing out a fresh union thunk * on every call would present an infinite graph and never terminate. * diff --git a/fjs/nanvm/README.md b/fjs/nanvm/README.md index a5b457510..44b9ea77c 100644 --- a/fjs/nanvm/README.md +++ b/fjs/nanvm/README.md @@ -32,7 +32,7 @@ identifiers — is not specific to this generator and lives in ## Writing a case Operands and expectations are ordinary JavaScript values, following -[`fjs/types/rtti`](../types/rtti/README.md)'s convention that a constant is its +[`fjs/rtti`](../rtti/README.md)'s convention that a constant is its own description: ```js diff --git a/fjs/nanvm/todo/reuse-edag-operators.md b/fjs/nanvm/todo/reuse-edag-operators.md index 767672dab..36b83b4e2 100644 --- a/fjs/nanvm/todo/reuse-edag-operators.md +++ b/fjs/nanvm/todo/reuse-edag-operators.md @@ -179,7 +179,7 @@ const constExp = v => { establishes `=>` anyway. The proof then validates every derived expression with the schema — -`validate(exp)` from `fjs/types/rtti/validate` over `exp` from +`validate(exp)` from `fjs/rtti/validate` over `exp` from `fjs/edag/module.f.mjs` — before running it. That is the runtime half of the coupling: a change to an operand shape or validation rule in the schema fails the corpus proof. (`validate` is shape-only and not identity-aware — it diff --git a/fjs/nanvm/types.ts b/fjs/nanvm/types.ts index 28fb98d1b..0f4f963a2 100644 --- a/fjs/nanvm/types.ts +++ b/fjs/nanvm/types.ts @@ -12,7 +12,7 @@ /** * A value under test, written as itself. * - * The shape follows `fjs/types/rtti`, where a constant is its own schema and a + * The shape follows `fjs/rtti`, where a constant is its own schema and a * thunk describes anything that needs a tag: `2.3`, `'a'`, `12n`, `[1, 2]`, * and `{ a: 1 }` mean exactly what they look like, and the only tagged forms * are the ones a literal cannot express — see {@link Special}. diff --git a/fjs/protocol/mcp/README.md b/fjs/protocol/mcp/README.md index b27e0b768..c9ad7026b 100644 --- a/fjs/protocol/mcp/README.md +++ b/fjs/protocol/mcp/README.md @@ -65,7 +65,7 @@ export const fromRegistry = ( Define argument schemas as RTTI: ```ts -import { string, number, option } from '../../types/rtti/module.f.mjs' +import { string, number, option } from '../../rtti/module.f.mjs' const addArgs = { a: number, b: number } as const const greetArgs = { name: string, greeting: option(string) } as const diff --git a/fjs/protocol/mcp/todo/README.md b/fjs/protocol/mcp/todo/README.md index 063ea865d..7249113c0 100644 --- a/fjs/protocol/mcp/todo/README.md +++ b/fjs/protocol/mcp/todo/README.md @@ -254,7 +254,7 @@ decoder via `validate` + static type via `Ts<>`). A representative subset: #### 3. rtti → JSON Schema printer — landed in `fjs/media/json/schema/module.f.mjs` MCP declares each tool's `inputSchema` as **JSON Schema**, not TypeScript. rtti -today only prints to TypeScript (`fjs/types/rtti/ts/`, `toTs`). To describe a tool +today only prints to TypeScript (`fjs/rtti/ts/`, `toTs`). To describe a tool *once* in rtti and expose it over MCP, `toJsonSchema` maps an rtti `Type` to a JSON Schema object — analogous to `toTs` but emitting `{ type, properties, required, items, … }`. This is the main capability MCP needs that JSON-RPC does @@ -312,6 +312,6 @@ server-answers-request. - `fjs/protocol/json_rpc/module.f.mjs` — the JSON-RPC 2.0 envelope - `fjs/media/json/schema/module.f.mjs` — rtti → JSON Schema printer -- `fjs/types/rtti/module.f.mjs` — schema combinators; `fjs/types/rtti/ts/` is the precedent for a printer +- `fjs/rtti/module.f.mjs` — schema combinators; `fjs/rtti/ts/` is the precedent for a printer - `fjs/effects/node/module.f.mjs` — stdio (`write` / stdin) and HTTP (`createServer` / `listen`) for transports - [Model Context Protocol](https://modelcontextprotocol.io/) · [JSON-RPC 2.0](https://www.jsonrpc.org/specification) diff --git a/fjs/protocol/mcp/todo/toolentry-hoist-invariants.md b/fjs/protocol/mcp/todo/toolentry-hoist-invariants.md index 4cb614b35..704af81d9 100644 --- a/fjs/protocol/mcp/todo/toolentry-hoist-invariants.md +++ b/fjs/protocol/mcp/todo/toolentry-hoist-invariants.md @@ -88,7 +88,7 @@ Note on the casts: removing `as any` / `as Ts` was tried and hits `TS2589: Type instantiation is excessively deep` at the `handle(r)` call — `Ts` for an unbound `T extends Type` exceeds the compiler's recursion limit, the same limitation already documented at -`fjs/types/rtti/parse/module.f.mjs:164`. The casts stay (with a comment citing +`fjs/rtti/parse/module.f.mjs:164`. The casts stay (with a comment citing TS2589), but they move to construction scope where they run once. ### Tasks @@ -97,7 +97,7 @@ TS2589), but they move to construction scope where they run once. - [ ] Hoist the `tools` descriptor array (and its `toJsonSchema` calls) to `fromRegistry`'s construction scope. - [ ] Add a comment on the remaining casts citing TS2589, mirroring - `fjs/types/rtti/parse/module.f.mjs:164`. + `fjs/rtti/parse/module.f.mjs:164`. - [ ] `npx tsc` clean; `fjs t` passes (`fjs/protocol/mcp/proof.f.mjs`, `fjs/mcp/proof.f.mjs`). ### Related @@ -105,5 +105,5 @@ TS2589), but they move to construction scope where they run once. - `fjs/protocol/mcp/module.f.mjs:158-168`, `:201-215` — the two factories. - AGENTS.md — "Hoist call-invariant computations out of function bodies"; curried-application placement rule. -- `fjs/types/rtti/parse/module.f.mjs:164` — precedent for the TS2589 cast +- `fjs/rtti/parse/module.f.mjs:164` — precedent for the TS2589 cast comment. diff --git a/fjs/rtti/README.md b/fjs/rtti/README.md index 79a993ab1..fce9dd4bb 100644 --- a/fjs/rtti/README.md +++ b/fjs/rtti/README.md @@ -4,6 +4,25 @@ See https://en.wikipedia.org/wiki/Run-time_type_information. A type-safe schema system for describing TypeScript types at runtime and validating unknown values against them. +## Why `fjs/rtti/` rather than `fjs/types/rtti/` + +`rtti` used to live under `fjs/types/`, but it is a peer of `djs` — `djs` the +data model, `rtti` the types described over it — not a member of `types/`. +Nothing under `types/` imports it; every consumer (`media`, `protocol`, `mcp`, +`edag`, `ci`, `emergent_testing`) is a peer of `types/`, the same relationship +every outside consumer of `types/list`, `types/result` or `types/object` has. +It does depend on several `types/*` modules (`object`, `result`, `list`, +`array`, `ts`, `phantom`), but that is consumption, not membership — the rest +of `fjs/` depends on those the same way. Its own outward dependencies +(`fjs/asserts`, `fjs/js/keywords`, `fjs/djs`) point sideways to other +top-level directories rather than down to a foundation `types/` sits under, +unlike the `types/*` modules that do reach outside (`bigint`, `bit_vec`, +`number`, `prime_field`, `string` → `fjs/common/monoid`; `uint8array` → +`fjs/text`). At 5789 lines it was also the largest thing filed under +`types/` — bigger than every sibling there and larger than every top-level +`fjs/` directory except `types` and `media` — while its siblings under +`types/` are single data structures and type-level helpers. + ## Modules - `module.f.mjs` — schema construction: defines `Type`, `Info`, and schema builder values @@ -54,7 +73,7 @@ for pass/fail callers, `orVisit`, and the primitive checks. The data form's the two cannot drift. For reading a value straight from JSON text against a schema, see -[`../../media/json/todo/rtti-parse.md`](../../media/json/todo/rtti-parse.md) — +[`../media/json/todo/rtti-parse.md`](../media/json/todo/rtti-parse.md) — one pass, no intermediate value, and it can reject `1.00000000000000001` against a `bigint`, which no reader over an already-materialized value can do. diff --git a/fjs/rtti/data/README.md b/fjs/rtti/data/README.md index b0b683bda..77918e265 100644 --- a/fjs/rtti/data/README.md +++ b/fjs/rtti/data/README.md @@ -1,7 +1,7 @@ # RTTI serializable data form A function-free, serializable representation of RTTI schemas, modeled after -[`fjs/bnf/data`](../../../bnf/data/). `toData` converts a thunk-form `Type` +[`fjs/bnf/data`](../../bnf/data/). `toData` converts a thunk-form `Type` (from [`../module.f.mjs`](../module.f.mjs)) into this form once, lazily, when a consumer actually needs it. @@ -128,7 +128,7 @@ design: The form is plain immutable data — no functions — so it serializes with the repository's data serializers. DJS -([`fjs/djs/serializer`](../../../djs/serializer/module.f.mjs)) covers the +([`fjs/djs/serializer`](../../djs/serializer/module.f.mjs)) covers the whole form, including `bigint` literal sets; plain `JSON.stringify` works only when no `bigint` literals are involved. One corner is shared by both: JSON's number model writes a `NaN` literal member as `null` and drops `-0`'s diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index ee0f9ed05..ab24b36bb 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -4,9 +4,9 @@ * A `proof.mjs` rather than a `proof.f.mjs`, and deliberately: the fixtures * here need in-place mutation — `Object.setPrototypeOf` to give an array a * prototype that supplies an index, `Object.assign` to put a key past the - * index range on one — which [`../../AGENTS.md`](../../AGENTS.md) §3.1 forbids + * index range on one — which [`../AGENTS.md`](../AGENTS.md) §3.1 forbids * in authored FunctionalScript. `shouldLoad` in - * [`../../dev/module.f.mjs`](../../dev/module.f.mjs) makes a plain + * [`../dev/module.f.mjs`](../dev/module.f.mjs) makes a plain * `proof.mjs` the opt-in home for exactly that, so the mutation stays out of * the `.f.mjs` proofs rather than being smuggled through them. * diff --git a/fjs/rtti/todo/668-rtti-function-types.md b/fjs/rtti/todo/668-rtti-function-types.md index 1423bc310..05c007d23 100644 --- a/fjs/rtti/todo/668-rtti-function-types.md +++ b/fjs/rtti/todo/668-rtti-function-types.md @@ -23,7 +23,7 @@ should not pretend it can prove all future calls are valid. > **Where the form lives is not settled here.** This section originally said > "add an **extern** RTTI form", and the sketch below is written that way. That > is one of two options, not a decision: -> [rtti-type-system](../../../../todo/rtti-type-system.md) made this issue its +> [rtti-type-system](../../../todo/rtti-type-system.md) made this issue its > stage 7, and the 7a tasks below ask whether an extern form can actually pay > for a `subset` path, a printer path, and whatever stage 4's stabilization > strategy requires (conditional — see the tasks below), or @@ -76,7 +76,7 @@ trusted. on statically checking a definition the compiler can read — see the next task. -Added by [rtti-type-system](../../../../todo/rtti-type-system.md), which makes +Added by [rtti-type-system](../../../todo/rtti-type-system.md), which makes this issue its stage 7. Completing only the tasks above would leave that stage unfinished, and general inference and declaration retirement blocked with it. @@ -104,7 +104,7 @@ are **7a** and run before stage 6; static checking of readable definitions is foreign call site. Where the generated declaration is wider than the schema — a closed struct, `rest(c, r)`, non-finite numbers and `-0`, all listed under - [the epic's `.d.ts` promise](../../../../todo/rtti-type-system.md) — the + [the epic's `.d.ts` promise](../../../todo/rtti-type-system.md) — the consumer can pass a value the declaration accepts and the schema rejects, with nothing between. That path is the epic's **stage 13** (ownership at the language boundary), not this issue. What stage 13 owes there splits by @@ -138,7 +138,7 @@ are **7a** and run before stage 6; static checking of readable definitions is - [ ] **7a — whatever stage 4's stabilization strategy requires of a function schema.** Not, as an earlier draft of this task said, "a canonical serializable form" full stop: that presumed stage 4 would take the - snapshot route, and [the epic](../../../../todo/rtti-type-system.md) now + snapshot route, and [the epic](../../../todo/rtti-type-system.md) now leaves purity-versus-snapshot open — with the stronger observation that a snapshot reaches only the consumers it is threaded through, while purity is a property of the binding and holds for every use at once. @@ -162,9 +162,9 @@ are **7a** and run before stage 6; static checking of readable definitions is ### Related -- [i668-emergent-testing-proof-type](../../../emergent_testing/todo/668-emergent-testing-proof-type.md) — +- [i668-emergent-testing-proof-type](../../emergent_testing/todo/668-emergent-testing-proof-type.md) — proof leaves need function-valued schemas if `Proof` is derived from RTTI. - [`../data`](../data/README.md) — serializable/function-free RTTI data form; extern function schemas may need to remain outside that core form. -- [rtti-type-system](../../../../todo/rtti-type-system.md) — the epic; this +- [rtti-type-system](../../../todo/rtti-type-system.md) — the epic; this document is its stage 7, and gates `//:` replacing `@type` on functions. diff --git a/fjs/rtti/todo/checked-const-pin.md b/fjs/rtti/todo/checked-const-pin.md index 1a371393c..aa5f92e58 100644 --- a/fjs/rtti/todo/checked-const-pin.md +++ b/fjs/rtti/todo/checked-const-pin.md @@ -39,7 +39,7 @@ export const casAddArgs = type({ content: string, type: or('text', 'base64', und `type` pins exactly as `as const` does — that is what the modifier means — and additionally checks `T extends Type` at the declaration, where the mistake is. See "Prefer a `const` type parameter to a cast at the call site" in -[`fjs/AGENTS.md`](../../../AGENTS.md) for the rule this would extend from +[`fjs/AGENTS.md`](../../AGENTS.md) for the rule this would extend from arguments to declarations. ## Why it is not obviously right @@ -49,7 +49,7 @@ arguments to declarations. invent a runtime value solely to represent a TypeScript-only declaration; this is not quite that — the checking is real and there is a value to return — but it is close enough to need an explicit decision rather than a drive-by. -- **Cyclic schemas may not survive it.** `../../../edag/module.f.mjs` spells its +- **Cyclic schemas may not survive it.** `../../edag/module.f.mjs` spells its node types out longhand with a comment explaining why: a const assertion applied to the returned array cannot resolve the cycle back through `array`/`object`/`op0` to `exp`, and declaration emit elides it to `any`. @@ -69,11 +69,11 @@ arguments to declarations. - [ ] Pick the name (`type` collides with the `type:` member in several schemas; `schema` may read better) and site it in `../module.f.mjs`. - [ ] Convert in batches, diffing declaration emit per batch, per the method in - [`../../../../todo/inline-type-casts.md`](../../../../todo/inline-type-casts.md). + [`../../../todo/inline-type-casts.md`](../../../todo/inline-type-casts.md). ## Related -- [`../../../../todo/inline-type-casts.md`](../../../../todo/inline-type-casts.md) +- [`../../../todo/inline-type-casts.md`](../../../todo/inline-type-casts.md) — the audit of inline casts, which excluded `@type {const}` wholesale. -- [`../../../edag/module.f.mjs`](../../../edag/module.f.mjs) — the cyclic +- [`../../edag/module.f.mjs`](../../edag/module.f.mjs) — the cyclic declarations that constrain the design. diff --git a/fjs/rtti/todo/data-validate-admits-non-djs-values.md b/fjs/rtti/todo/data-validate-admits-non-djs-values.md index 2287cbcae..8b5ff52e2 100644 --- a/fjs/rtti/todo/data-validate-admits-non-djs-values.md +++ b/fjs/rtti/todo/data-validate-admits-non-djs-values.md @@ -11,7 +11,7 @@ disagree about values that are neither primitives nor arrays nor plain objects. The thunk readers guard object positions with `isObject`, which is `typeof value === 'object' && !isArray(value) && value !== null` ([`common`](../common/module.f.mjs), via -[`fjs/types/object`](../../object/module.f.mjs)) — so a function or a symbol +[`fjs/types/object`](../../types/object/module.f.mjs)) — so a function or a symbol fails it. `data`'s `unionValidate` instead dispatches on primitives and arrays and lets **everything else** fall through to object validation: @@ -87,7 +87,7 @@ so agreeing on acceptance is the contract, not an extra. `unknown` means is unsettled — the module and its README promise DJS-compatible values, `Ts<>` excludes functions and symbols, both thunk readers have `unknown: () => ok`, and the printer emits TypeScript's unrestricted `unknown`. -[rtti-type-system](../../../../todo/rtti-type-system.md) records that +[rtti-type-system](../../../todo/rtti-type-system.md) records that disagreement and gates stage 11 on resolving it. Which repair is correct here follows from it: @@ -121,7 +121,7 @@ is an investigation, not a plan. ### Tasks - [ ] **First**, settle what an exported `unknown` means — the decision - [rtti-type-system](../../../../todo/rtti-type-system.md) gates stage 11 + [rtti-type-system](../../../todo/rtti-type-system.md) gates stage 11 on. The repair below depends on it. - [ ] Investigate the mechanism: no-kind representation versus explicit top, whether a narrowed `unknown` needs recursive descent, cycle handling if @@ -145,7 +145,7 @@ is an investigation, not a plan. ### Related -- [rtti-type-system](../../../../todo/rtti-type-system.md) — its **stage 4** +- [rtti-type-system](../../../todo/rtti-type-system.md) — its **stage 4** proposes serializing a compile-time schema through `toData` and reusing that form at run time, to stop a stateful thunk from presenting different schemas in different phases. That remedy assumes the two readers accept the same diff --git a/fjs/rtti/todo/excluded-string-values.md b/fjs/rtti/todo/excluded-string-values.md index 1f5271544..d472440b7 100644 --- a/fjs/rtti/todo/excluded-string-values.md +++ b/fjs/rtti/todo/excluded-string-values.md @@ -9,11 +9,11 @@ rtti's `Type` ADT has no negation. `Const`, `Tag0`/`Tag1`, and `Or` are all *pos — they state what a value must match, never what it must not be. There is no way to write "any string except these" as a schema. -## Why it matters for `../../../edag` +## Why it matters for `../../edag` -`index`'s `string` branch (`../../../edag/module.f.mjs`) is meant to admit any property +`index`'s `string` branch (`../../edag/module.f.mjs`) is meant to admit any property name *except* `'__proto__'` and `'constructor'` — see "the current decision is to -prohibit both" in [`../../../../spec/todo/2330-property-accessor.md`](../../../../spec/todo/2330-property-accessor.md). +prohibit both" in [`../../../spec/todo/2330-property-accessor.md`](../../../spec/todo/2330-property-accessor.md). Today it admits every string, prohibited names included: `validate(exp)(['.', 'a', 'constructor'])` returns `ok`. @@ -33,9 +33,9 @@ layered check is probably simpler than growing the `Type` ADT for one consumer. ## Related -- [`../../../edag/module.f.mjs`](../../../edag/module.f.mjs) — `index`'s doc comment +- [`../../edag/module.f.mjs`](../../edag/module.f.mjs) — `index`'s doc comment notes the gap and points here. -- [`../../../../spec/todo/2330-property-accessor.md`](../../../../spec/todo/2330-property-accessor.md) +- [`../../../spec/todo/2330-property-accessor.md`](../../../spec/todo/2330-property-accessor.md) — the prohibited-name list this would enforce. - [Closed containers](../README.md#closed-containers) — the other extension to the `Type` ADT (exact/closed containers), for comparison: it shipped because its diff --git a/fjs/rtti/todo/export-node-accessors.md b/fjs/rtti/todo/export-node-accessors.md index 97078d4e8..35c521a82 100644 --- a/fjs/rtti/todo/export-node-accessors.md +++ b/fjs/rtti/todo/export-node-accessors.md @@ -12,10 +12,10 @@ reach a private comparison through the public `cmp`. Resolving a `Node` through the rule set: ```js -// fjs/types/rtti/data/module.f.mjs:331 +// fjs/rtti/data/module.f.mjs:331 const resolve = rules => n => typeof n === 'string' ? assertNotNullish(at(n)(rules)) : n -// fjs/types/rtti/ts/module.f.mjs:166-169 — the same lookup, open-coded +// fjs/rtti/ts/module.f.mjs:166-169 — the same lookup, open-coded const admitsUndefined = ctx => n => { const u = typeof n === 'string' ? assertNotNullish(at(n)(ctx.rules)) : n ... @@ -24,10 +24,10 @@ const admitsUndefined = ctx => n => { Testing "is this the top set": ```js -// fjs/types/rtti/data/module.f.mjs:268 +// fjs/rtti/data/module.f.mjs:268 const isTop = n => typeof n !== 'string' && cmpUnion(n, unknown) === 0 -// fjs/types/rtti/ts/module.f.mjs:197 +// fjs/rtti/ts/module.f.mjs:197 const isTop = u => cmp([{}, u])([{}, top]) === 0 ``` @@ -51,9 +51,9 @@ in `rtti/ts`: ### Tasks -- [ ] Export `resolve`, `isTop`, `isNever` from `fjs/types/rtti/data/module.f.mjs` +- [ ] Export `resolve`, `isTop`, `isNever` from `fjs/rtti/data/module.f.mjs` with JSDoc; add proof coverage for the exported forms. -- [ ] Rewrite `admitsUndefined` and `isTop` in `fjs/types/rtti/ts/module.f.mjs` +- [ ] Rewrite `admitsUndefined` and `isTop` in `fjs/rtti/ts/module.f.mjs` through the imports; drop the fake-`Data` `cmp` trick. - [ ] `npx tsc`, `fjs t` — rtti proofs pass unchanged. diff --git a/fjs/rtti/todo/identity-aware-parse.md b/fjs/rtti/todo/identity-aware-parse.md index a8444a28e..c9c4b983f 100644 --- a/fjs/rtti/todo/identity-aware-parse.md +++ b/fjs/rtti/todo/identity-aware-parse.md @@ -69,11 +69,11 @@ arbitrary code execution and a `RangeError` here is not the interesting attack. `edag` ever gains a wire format with back-references, cycles become reachable through that channel too, and this reasoning should be revisited then. -## Why it matters for `../../../edag` +## Why it matters for `../../edag` The EDAG is the one schema in this codebase where reference identity between operand positions *is* part of the value's meaning — see -[`edag-stage1-discussion.md`, "The core invariant"](../../../../todo/edag-stage1-discussion.md#the-core-invariant): +[`edag-stage1-discussion.md`, "The core invariant"](../../../todo/edag-stage1-discussion.md#the-core-invariant): `["[]", x, x]` and `["[]", ["{}"], ["{}"]]` are different functions specifically because sharing is observable, and hashing is defined as "structural identity of the graph as written." A reader that needs to reconstruct an EDAG from a serialized or otherwise @@ -111,7 +111,7 @@ work for that input again: `$ref`-style back-references, or a JS value someone else already deduplicated) — a purely textual/byte serialization needs its own explicit sharing encoding (back-references by index, similar to `$defs`/`$ref` in - `../../../media/json/schema/module.f.mjs`) before there is anything to key a + `../../media/json/schema/module.f.mjs`) before there is anything to key a `WeakMap` on. - For `validate`, the memo only needs to record "already validated this reference, and it passed" — no output to reuse, since `validate` returns the input as-is. This is a @@ -125,9 +125,9 @@ generic engine or as an edag-specific layer on top — isn't decided yet. ## Related -- [`../../../edag/module.f.mjs`](../../../edag/module.f.mjs) — the schema this matters +- [`../../edag/module.f.mjs`](../../edag/module.f.mjs) — the schema this matters for; references this TODO. -- [`../../../../todo/edag-stage1-discussion.md`](../../../../todo/edag-stage1-discussion.md) +- [`../../../todo/edag-stage1-discussion.md`](../../../todo/edag-stage1-discussion.md) — "The core invariant" and subject 1 (sharing is semantic), subject 4 (the contrasting case where identity is *not* observable), and "Validation" (the public-input threat model this DoS angle falls under). diff --git a/fjs/rtti/todo/kindset-eliminator.md b/fjs/rtti/todo/kindset-eliminator.md index 95b9eda51..63e2d8801 100644 --- a/fjs/rtti/todo/kindset-eliminator.md +++ b/fjs/rtti/todo/kindset-eliminator.md @@ -10,16 +10,16 @@ consumer re-spells the trichotomy inline. Unary eliminations alone: ```js -// fjs/types/rtti/data/module.f.mjs:852 +// fjs/rtti/data/module.f.mjs:852 const kindRefs = f => k => k === undefined || k === true ? [] : k.flatMap(f) -// fjs/types/rtti/data/module.f.mjs:956-965 +// fjs/rtti/data/module.f.mjs:956-965 const patternsValidate = (k, item, value) => { if (k === undefined) { return verror('unexpected value') } if (k === true) { return ok(value) } ... -// fjs/types/rtti/ts/module.f.mjs:130-133 +// fjs/rtti/ts/module.f.mjs:130-133 const kindToTs = (k, whole, item) => k === undefined ? [] : k === true ? [whole] : @@ -30,7 +30,7 @@ and the same `undefined || true` guard is duplicated twice within each of two structurally identical union rewriters: ```js -// fjs/types/rtti/data/module.f.mjs:493-501 +// fjs/rtti/data/module.f.mjs:493-501 const mapChildren = f => u => ({ ...u, ...(u.array === undefined || u.array === true ? {} : { @@ -41,7 +41,7 @@ const mapChildren = f => u => ({ }), }) -// fjs/types/rtti/data/module.f.mjs:519-527 — same skeleton, different transform +// fjs/rtti/data/module.f.mjs:519-527 — same skeleton, different transform const dropSubsumedUnion = ctx => u => ({ ...u, ...(u.array === undefined || u.array === true ? {} : { @@ -97,7 +97,7 @@ at `kindFold` as the statement of the contract. ### Tasks -- [ ] Add `kindFold` to `fjs/types/rtti/data/module.f.mjs`; rewrite `kindRefs`, +- [ ] Add `kindFold` to `fjs/rtti/data/module.f.mjs`; rewrite `kindRefs`, `patternsValidate`, and `ts`'s `kindToTs` through it. - [ ] Extract `mapPatternKinds`; re-derive `mapChildren` and `dropSubsumedUnion`. - [ ] `npx tsc`, `fjs t` — pure refactor, rtti proofs pass unchanged. diff --git a/fjs/rtti/todo/option-as-omission.md b/fjs/rtti/todo/option-as-omission.md index f475cb8a9..07bb44a07 100644 --- a/fjs/rtti/todo/option-as-omission.md +++ b/fjs/rtti/todo/option-as-omission.md @@ -51,7 +51,7 @@ pick does not survive JSON (`[42, undefined]` → `'[42,null]'` → rejected). T dissolves rather than decides. TypeScript is on the other side of this already: this repo sets -`exactOptionalPropertyTypes: true` ([`../../../../tsconfig.json`](../../../../tsconfig.json)), +`exactOptionalPropertyTypes: true` ([`../../../tsconfig.json`](../../../tsconfig.json)), so `x?: string` and `x: string | undefined` are distinct there while RTTI conflates them and renders the hybrid `{readonly "x"?: undefined|string}`. @@ -250,7 +250,7 @@ two renderers agree — today the runtime printer prints the open tail `readonly[1,number?]`. There is a **third** renderer over the data form: -`../../../media/json/schema/module.f.mjs` derives `required` and `minItems` from +`../../media/json/schema/module.f.mjs` derives `required` and `minItems` from `admitsUndefined`, and drops `undefined` from an optional member's schema with `stripUndefined`. Stage 2 splits those two uses, which today are one thing: @@ -334,17 +334,17 @@ One PR, now that stage 1 has landed: build time; a missed doc is a working example that quietly builds the wrong schema for whoever copies it. Twenty sites across eight files: `../README.md` (3), `../ts/README.md` (2), `../data/README.md` (3), - `../../../protocol/mcp/README.md:71` (a copy-me - `greeting: option(string)`), `../../../media/revision/README.md` (5, + `../../protocol/mcp/README.md:71` (a copy-me + `greeting: option(string)`), `../../media/revision/README.md` (5, including the `option(true)` presence-flag idiom it recommends twice), - `../../../media/note/README.md` (2), - `../../../media/note/todo/extend-note-format.md` (2), and - `../../../AGENTS.md` — `:383` writes `option(...)` among the schema + `../../media/note/README.md` (2), + `../../media/note/todo/extend-note-format.md` (2), and + `../../AGENTS.md` — `:383` writes `option(...)` among the schema references, a call form it stops having, while `:405` lists `option` as a - bare name among `types/rtti`'s exports, so that one is a description to + bare name among `rtti`'s exports, so that one is a description to re-word rather than a spelling to fix. Two near-misses stay out: `option` in - `../../../bnf/todo/207.md` is `bnf`'s own combinator, and the `option(s)` - in `../../../cas/evo/todo/cache-staleness.md` is English, not code. + `../../bnf/todo/207.md` is `bnf`'s own combinator, and the `option(s)` + in `../../cas/evo/todo/cache-staleness.md` is English, not code. - [ ] The **JSDoc** sites, which that list does not cover: it is a markdown inventory, and a comment is no more compiled than a `.md` file is, so the two sweeps between them still leave these eleven untouched, in six files. @@ -364,7 +364,7 @@ One PR, now that stage 1 has landed: derivation. `../validate/module.f.mjs` (`:18`, `:298`) publishes `b: option(string)` in its parse-vs-validate contrast and in the exported `validate`'s `@example` — copy-me code in the reader's own API docs. - `../../../media/revision/proof.f.mjs:125` names the `option(true)` + `../../media/revision/proof.f.mjs:125` names the `option(true)` presence-only idiom its README recommends. Sweep JSDoc explicitly rather than trusting the markdown pass: the earlier revision of this item said "twenty sites across eight files" and meant twenty *markdown* sites, which @@ -576,7 +576,7 @@ One PR, now that stage 1 has landed: `[1, or(option, number)]` print required members while `Ts<>` and both readers treat them as optional — and the two-renderer pin below could not hold. Move them to the absent bit and update `../ts/proof.f.mjs`. -- [ ] `../../../media/json/schema/module.f.mjs`: move `admitsUndefined` (and so +- [ ] `../../media/json/schema/module.f.mjs`: move `admitsUndefined` (and so `required`/`minItems`) to the absent bit, leave `stripUndefined` on `undefined`, and update `./proof.f.mjs` — a third renderer over the data form, and the one whose output is wrong rather than merely imprecise if it @@ -656,7 +656,7 @@ One PR, now that stage 1 has landed: renders required. Add a `_TsRaw`-level check (`CheckRaw = Equal>`) for the raw half, since that is the only half with teeth here — and update the **contract that mandates the weak pair**: - `../../phantom/types.ts:26-38` tells every `Phantom` user to guard with + `../../types/phantom/types.ts:26-38` tells every `Phantom` user to guard with two `Check`s "or `Check3`, which pairs the two into one assert", both of which route through public `Ts`. A caller following that documentation after stage 2 silently renders a wrapped optional member required. The @@ -706,10 +706,6 @@ One PR, now that stage 1 has landed: - [excluded-string-values](./excluded-string-values.md) — the other proposed `Type` ADT extension, and the bar it sets: a data-form mapping worked out end to end before code. -- [move-rtti-out-of-types](../../../todo/move-rtti-out-of-types.md) — if that - lands first, every relative path in this file is re-anchored. Nothing here - depends on the location, so it is a mechanical re-base, not a redesign; the - order just needs picking rather than discovering. - [#1719](https://github.com/functionalscript/functionalscript/pull/1719) — **collides with both stages.** The epic makes RTTI the single source of truth for the type system and works its examples in the eDSL as it stands today — diff --git a/fjs/rtti/todo/parse-omits-undefined-members.md b/fjs/rtti/todo/parse-omits-undefined-members.md index efc67ca01..7d2533f3c 100644 --- a/fjs/rtti/todo/parse-omits-undefined-members.md +++ b/fjs/rtti/todo/parse-omits-undefined-members.md @@ -47,7 +47,7 @@ to apply. So today the two kinds disagree about their own output in a way nothing in the module states, and the kind that disagrees loses data. The same follows for any format without `undefined` (CBOR, and the canonical -byte-level forms `../../../cas` hashes): two values that are equal under RTTI +byte-level forms `../../cas` hashes): two values that are equal under RTTI serialize differently, so they address differently. ## Proposal diff --git a/fjs/rtti/todo/prefix-then-rest-tuple.md b/fjs/rtti/todo/prefix-then-rest-tuple.md index bfc651bbd..f4180e344 100644 --- a/fjs/rtti/todo/prefix-then-rest-tuple.md +++ b/fjs/rtti/todo/prefix-then-rest-tuple.md @@ -25,7 +25,7 @@ motivating consumer in this codebase, so there is nothing open here to track. ## Related -- [`../../../edag/module.f.mjs`](../../../edag/module.f.mjs) — `array`/`object` use the +- [`../../edag/module.f.mjs`](../../edag/module.f.mjs) — `array`/`object` use the nested form. - [Open containers](../README.md#open-containers) — `rest(c, r)` states a prefix and a homogeneous tail, so the shape above is now spellable as one schema. diff --git a/fjs/rtti/todo/proof-shared-asserts.md b/fjs/rtti/todo/proof-shared-asserts.md index 6effb837a..eac3a7535 100644 --- a/fjs/rtti/todo/proof-shared-asserts.md +++ b/fjs/rtti/todo/proof-shared-asserts.md @@ -5,7 +5,7 @@ ## Problem -`fjs/types/rtti/parse/proof.f.mjs:28` hand-rolls an `unwrap` that duplicates +`fjs/rtti/parse/proof.f.mjs:28` hand-rolls an `unwrap` that duplicates `unwrap` from `fjs/types/result/module.f.mjs:53` — assert `'ok'`, return the payload. @@ -13,7 +13,7 @@ payload. This issue used to be about sharing `assertOk` / `assertError` / `assertErrorPath` and roughly 80% of the proof tree between -`fjs/types/rtti/validate/proof.f.mjs` and `fjs/types/rtti/parse/proof.f.mjs`, +`fjs/rtti/validate/proof.f.mjs` and `fjs/rtti/parse/proof.f.mjs`, which were copy-pasted modulo the checker name. That duplication is gone: `validate` was deleted and `parse` is the only schema-form reader, so there is one proof file and nothing to share it with. diff --git a/fjs/rtti/todo/shared-helper-reuse.md b/fjs/rtti/todo/shared-helper-reuse.md index 2fce7e80f..15a7d845b 100644 --- a/fjs/rtti/todo/shared-helper-reuse.md +++ b/fjs/rtti/todo/shared-helper-reuse.md @@ -11,7 +11,7 @@ canonical owners already exist under `fjs/types`: Association-list lookup, byte-identical modulo parameter names: ```js -// fjs/types/rtti/data/module.f.mjs:614-619 +// fjs/rtti/data/module.f.mjs:614-619 const assoc = (list, key) => { for (const [k, v] of list) { if (k === key) { return v } @@ -19,7 +19,7 @@ const assoc = (list, key) => { return undefined } -// fjs/types/rtti/ts/module.f.mjs:102-107 +// fjs/rtti/ts/module.f.mjs:102-107 const idOf = (ids, name) => { for (const [k, v] of ids) { if (k === name) { return v } @@ -31,9 +31,9 @@ const idOf = (ids, name) => { Order-preserving dedup, byte-identical: ```js -// fjs/types/rtti/data/module.f.mjs:392 +// fjs/rtti/data/module.f.mjs:392 const dedup = list => list.filter((n, i) => list.indexOf(n) === i) -// fjs/types/rtti/ts/module.f.mjs:172 +// fjs/rtti/ts/module.f.mjs:172 const dedup = list => list.filter((s, i) => list.indexOf(s) === i) ``` @@ -41,10 +41,10 @@ And uncurried re-spellings of `function/compare.cmp` and `function/operator.strictEqual`: ```js -// fjs/types/rtti/data/module.f.mjs:74, :93 — twice in one file +// fjs/rtti/data/module.f.mjs:74, :93 — twice in one file const cmpString = (a, b) => a < b ? -1 : a > b ? 1 : 0 const cmpBigint = (a, b) => a < b ? -1 : a > b ? 1 : 0 -// fjs/types/rtti/data/module.f.mjs:334 +// fjs/rtti/data/module.f.mjs:334 const strictEqual = (a, b) => a === b // the owners: diff --git a/fjs/rtti/ts/module.f.mjs b/fjs/rtti/ts/module.f.mjs index f44a1308a..1a8adc0f1 100644 --- a/fjs/rtti/ts/module.f.mjs +++ b/fjs/rtti/ts/module.f.mjs @@ -3,7 +3,7 @@ * See `./types.ts` for `Ts` and the `*Ts` transformer types. * * The printer routes through the serializable RTTI data form - * (`fjs/types/rtti/data`): `thunk RTTI → toData → dataToTs`. The data form + * (`fjs/rtti/data`): `thunk RTTI → toData → dataToTs`. The data form * is a finite graph, so recursive schemas — which the thunk graph represents * as self-referencing functions with no leaves — terminate: every named rule * becomes a TypeScript type-alias definition and every graph edge prints as diff --git a/fjs/rtti/ts/types.ts b/fjs/rtti/ts/types.ts index 14f6e71d8..5ed2be2a1 100644 --- a/fjs/rtti/ts/types.ts +++ b/fjs/rtti/ts/types.ts @@ -257,7 +257,7 @@ export type StructTs = * `Ts<>` detects the phantom key and returns `MyType` directly without recursing: * * ```ts - * import { type Phantom } from '../../phantom/types.ts' + * import { type Phantom } from '../../types/phantom/types.ts' * * type MyType = { readonly self?: MyType } * const myThunk = () => ['const', myConst] as const diff --git a/fjs/todo/group-fs-subdirectories-by-concern.md b/fjs/todo/group-fs-subdirectories-by-concern.md index 8a385ddae..dadcd3584 100644 --- a/fjs/todo/group-fs-subdirectories-by-concern.md +++ b/fjs/todo/group-fs-subdirectories-by-concern.md @@ -32,9 +32,6 @@ Create `fjs/common/` for cross-cutting reusable algorithms, starting by moving ` - Tooling bucket for `bnf`, `fsc`, and possibly `js` (grammar/compiler tooling; the content-facing formats go to `fjs/media/`, see below). -- Promote `types/rtti` to `fjs/rtti` — the same membership rule as item 2, applied - to the largest thing in `types/`. See - [move-rtti-out-of-types](./move-rtti-out-of-types.md). - Storage bucket for `cas` + `sul`; testing bucket for `asserts` + `emergent_testing`. ### 4. `fjs/media/` — content formats and media-type detection @@ -171,6 +168,8 @@ API (no `exports` map), so every move is a breaking change. The first wave is - [x] Move `fjs/html/` → `fjs/media/html/` (one PR). - [x] `fjs/media/revision/` arrived as new code (the `vnd.fjs.revision` format) — no move needed. - [x] Rename `fjs/mime/` → `fjs/media/type/`. +- [x] Promote `types/rtti` to `fjs/rtti` — the same membership rule as item 2, applied + to the largest thing in `types/`. - [ ] Later: move `fjs/djs/` → `fjs/media/djs/`. - [x] Update all relative imports referencing the moved modules. - [ ] Update `deno.json` `exports` map and run `npm run update` (no `exports` map exists in `deno.json` currently; nothing to update). **When a map is first introduced it must enumerate every `module.f.mjs` then present** — a partial map silently restricts a package that is unrestricted today. Modules proposed meanwhile are counting on this: `fjs/media/json/grammar` ([bnf-grammar-single-owner](../media/json/todo/bnf-grammar-single-owner.md)) and `fjs/effects/{all,sandbox,console,test}` ([node-module-layering](../effects/todo/node-module-layering.md)) each record that their registration lands here rather than in their own change. diff --git a/fjs/todo/move-rtti-out-of-types.md b/fjs/todo/move-rtti-out-of-types.md deleted file mode 100644 index e2943e3e5..000000000 --- a/fjs/todo/move-rtti-out-of-types.md +++ /dev/null @@ -1,203 +0,0 @@ -## Move `rtti` out of `fjs/types/` - -**Priority:** P4 -**Status:** open - -### Problem - -`fjs/types/rtti/` is a subsystem filed among leaf utilities. Four things say so. - -**Size.** It is 5789 lines — 38% of all of `fjs/types/` (15063), and larger than -every top-level `fjs/` directory except `types` and `media`: - -| LOC | directory | -|---|---| -| 5789 | `fjs/types/rtti/` | -| 1624 | `fjs/types/btree/` | -| 1074 | `fjs/types/bit_vec/` | -| 4975 | `fjs/djs/` (for scale — a top-level peer) | - -It has six modules (root, `common`, `parse`, `validate`, `data`, `ts`), a README -tree of its own, and eleven open todos. Its siblings under `types/` are single -data structures and type-level helpers. - -**Nobody under `types/` uses it.** Zero imports; `types/phantom/types.ts` names -it only in a doc comment. All 64 import references come from `media` 33, -`protocol` 11, `mcp` 8, `edag` 6, `ci` 3 and `emergent_testing` 3 — every one -of them a peer of `types/`, not a member. It is simultaneously a heavily-used -module and the least-connected one in the directory that holds it. - -**Consuming `types/` is not membership.** `rtti` imports `types/object`, -`types/result`, `types/list`, `types/array`, `types/ts` and `types/phantom` — -which is exactly what the rest of `fjs/` does: repo-wide, `types/list` has 82 -import references from 14 top-level directories, `types/result` 78 from 10, -`types/object` 59 from 16. That is outside-consumer behaviour. - -**Its outward dependency points sideways, not down.** Other `types/*` modules do -reach outside — `bigint`, `bit_vec`, `number`, `prime_field` and `string` import -`fjs/common/monoid`, `uint8array` imports `fjs/text`, and several proofs import -`media/json`'s `stringify` — but those targets sit *below* `types/`: -`fjs/common/` is the cross-cutting-algorithm bucket that `monoid` was moved out -of `types/` to create, and `text/` is the character-encoding layer under the -media formats. `rtti/ts/module.f.mjs` imports `fjs/js/keywords` at runtime, -language tooling of the same rank as `bnf` and `fsc`; `rtti`'s parse and -validate proofs import `fjs/djs/types.ts`. Those are peers, so `rtti` is not -sitting at the foundation the way the rest of `types/` is. - -Positively: `fjs/rtti` beside `fjs/djs` reads as what the two are — `djs` the -data model, `rtti` the types described over it. - -### Proposal - -Move `fjs/types/rtti/` → `fjs/rtti/` whole: modules, proofs, `types.ts` -companions, `README.md` files, and `todo/`. No file is split and no code -changes; only import paths move. - -Directory paths are the public API — the package publishes the tree with no -`exports` map — so this is a breaking change for downstream importers of -`functionalscript/fjs/types/rtti/…`, and belongs in `changelog/unreleased/`. -That argues for doing it now rather than after more consumers accumulate. - -The move is its own PR, touching nothing else, per the one-move-per-PR rule in -[group-fs-subdirectories-by-concern](./group-fs-subdirectories-by-concern.md). - -Scope of the path edits: - -- 30 code files outside `rtti/` import it (`media` 14, `protocol` 6, `mcp` 5, - `ci` 2, `edag` 2, `emergent_testing` 1). Each drops the `types/` segment and - keeps its `../` count — `../types/rtti/…` → `../rtti/…` from `edag`, - `../../../types/rtti/…` → `../../../rtti/…` from `media/json/rtti`. Four more - name the old path in doc comments only - — `edag/types.ts`, `media/json/module.f.mjs`, `nanvm/types.ts`, - `types/phantom/types.ts`. -- 58 import lines in 16 files *inside* `rtti/` re-anchor, by one rule that - holds at every nesting depth: **a path into `types/` keeps its `../` count - and gains a `types/` segment; a path out of `types/` loses one `../`.** - Nesting depth is preserved either way — do not rewrite by pattern. From the - `rtti/` root, `../object/…` → `../types/object/…` and `../../asserts` → - `../asserts`; from a subdirectory one level down, `../../object/…` → - `../../types/object/…` and `../../../djs` → `../../djs`. The 39 inward paths - are `object` 12, `result` 10, `ts` 9, `array` 3, `phantom` 3, `list` 2; the - 19 outward are `asserts` 16, `djs` 2, `js` 1. -- Markdown breaks in **both** directions, in four classes. **Inventory by - resolving paths, not by grepping `types/rtti`** — string matching misses - every reference that reaches the subtree without spelling that segment, and - link-syntax matching misses every path written in inline code or a fence. - - *Into `rtti/`* — **37 links in 19 files** (`changelog/` aside), found by - resolving every relative markdown target and asking whether it lands in - the subtree. 36 spell `types/` and simply drop it, keeping their `../` - count. The 37th does not: `fjs/types/todo/66d-ts-printer-tuple-readonly-fold.md` - reaches a sibling as `../rtti/ts/module.f.mjs`, so it *gains* a `../` and - becomes `../../rtti/ts/module.f.mjs` — the opposite direction from the - rule covering the other 36, and invisible to both a `types/rtti` grep and - a check confined to the moved subtree. A further 14 files name the old - path in prose or a fence and are edited by hand. - - *Out of `rtti/`, as links* — the moved files' own outward links break too, - easy to miss because nothing outside the subtree changes. 15 links in 7 - files, every one leaving `types/`, so every one loses a `../`: - `README.md` reaches `media/json/todo/rtti-parse.md` as `../../media/…` → - `../media/…`; `data/README.md` has `../../../bnf/…` and `../../../djs/…`; - `todo/` has four `../../../edag/module.f.mjs`, four - `../../../../todo/…`, two `../../../../spec/todo/…`, one - `../../../emergent_testing/…` and one `../../../AGENTS.md`. None targets a - `types/` sibling, so none gains a `types/` segment. - - *Out of `rtti/`, not as links* — 16 relative refs in 6 files written as - inline code or inside fences, so no link checker sees them, each losing a - `../` like the links: `../../../cas` in - `todo/parse-omits-undefined-members.md`, - `../../../media/json/schema/module.f.mjs` in `todo/identity-aware-parse.md`, - and the `edag`/`spec`/`todo` refs across `todo/`. - - *Self-referential* — 21 literal `fjs/types/rtti/…` paths in 4 moved todo - files (`shared-helper-reuse.md` 6, `export-node-accessors.md` 6, - `kindset-eliminator.md` 6, `proof-shared-asserts.md` 3), naming the very - subtree being moved. Repo-root-absolute, so no `../` arithmetic — a plain - substitution to `fjs/rtti/…`. -- `changelog/` entries keep the old path — all 16, not only the 9 already - released. The 7 in `unreleased/` (1653, 1657, 1680, 1683, 1687, 1708, 1712) - have not shipped, so "the record of what shipped" is the wrong reason for - them. The right one is that an entry records what one pull request changed, - against the tree as it stood then, and releasing renames `unreleased/` to - `/` "keeping the entry files exactly as they are" - ([changelog/README.md](../../changelog/README.md#layout)) — so rewriting an - unreleased entry's paths would make it describe a tree that never existed - when its PR landed. What tells a reader the module moved is the move's own - entry naming the rename, not a retroactive edit to its neighbours. If the - maintainer prefers the 7 rewritten so a single release reads coherently, - that is a defensible opposite call and belongs here as a decision. - -`fjs/media/json/rtti/` — the JSON binding — keeps its name and its place. It is -named after what it binds, and no path collides. - -### Tasks - -- [ ] `git mv fjs/types/rtti fjs/rtti` (keeps history for `--follow`). -- [ ] Re-anchor the 58 external imports inside `fjs/rtti/`, by the depth rule - above rather than by a blanket pattern substitution. -- [ ] Update the 30 importing code files, and the four doc-comment references. -- [ ] Update the 37 inbound links and the 14 prose mentions outside - `changelog/`, including `fjs/AGENTS.md` (§3 references `types/rtti`, - `types/rtti/parse`, `types/rtti/validate`) and the sibling link in - `fjs/types/todo/66d-ts-printer-tuple-readonly-fold.md`, which gains a - `../` rather than dropping a segment. -- [ ] Re-anchor the moved subtree's outward references — 15 links plus 16 - non-link refs in inline code and fences — each losing one `../`, and - rewrite the 21 literal `fjs/types/rtti/…` self-paths. -- [ ] Check every path reference resolves, in both directions, by resolving - targets rather than grepping for `types/rtti`, and covering inline code - and fences as well as link syntax. Nothing here fails a test: `npx tsc` - and `fjs test` see none of it. -- [ ] Add `changelog/unreleased/.md`, named by the move PR's own number, - with the entry prefixed **verbatim** `**BREAKING CHANGES:**`. That marker - is not decoration: it is the mechanical version-bump trigger — one such - entry anywhere in `unreleased/` means the release cannot be a patch - ([changelog/README.md](../../changelog/README.md), the table at `:98`), - so pre-1.0 this ships as `0.47.0`, not `0.46.2`. Omit it and removing the - published `functionalscript/fjs/types/rtti/…` path goes out under a patch - bump. AGENTS.md pairs the prefix with updating every importer in the same - PR, which the tasks above already do. -- [ ] `npm run update`. The move edits `fjs/ci/common/module.f.mjs`, which is - generator source, and `ci-update` regenerates committed files that CI - drift-checks and fails on when stale (see `fjs/nanvm/update/module.f.mjs`). - Expect a no-op — rtti's location is not embedded in any generated output — - and commit whatever it does write. -- [ ] `npx tsc`, `fjs test`, `npm run cov` — proofs and 100% coverage unchanged. -- [ ] `cargo test`, `cargo clippy -- -D warnings`, `cargo fmt -- --check`. - The move touches no Rust — nothing under `nanvm-lib/` references rtti — - but that governs only whether running them locally is worth the time, - not whether they gate the PR: `.github/workflows/ci.yml` runs all three - on every pull request with no path filter (24 `cargo clippy` invocations - across the platform jobs, `cargo fmt -- --check` at `:312`), so - AGENTS.md's "only if you touched Rust" is advice for the local loop and - CI is the enforcer. Expect them untouched; they become a real check only - if `npm run update` regenerates the Rust operator tests. -- [ ] Delete this file, and close out the umbrella entry. `git mv` moves only the - rtti subtree, so this issue would survive its own completion, and - `todo/README.md` requires the fixing PR to delete its issue, capturing any - design decision in a `README.md` first — here the membership argument, why - `rtti` is a peer of `djs` rather than a member of `types/`, which belongs - in the moved `fjs/rtti/README.md` and should outlive this file. Turn the - `Later candidates` bullet in - [group-fs-subdirectories-by-concern](./group-fs-subdirectories-by-concern.md) - into a done entry, the way its item 1 records the `basen` move. - -### Related - -- **Merge-order hazard with the RTTI epic** - ([#1719](https://github.com/functionalscript/functionalscript/pull/1719)), - in two facets, neither visible to any inventory run on this branch. - - `todo/rtti-type-system.md` exists only on that branch and is dense in - `types/rtti` path references — 45 at its head `f736b79`, 43 a few commits - earlier. **Treat any figure here as a snapshot**: that branch is active, so - re-measure at merge time rather than trusting this line. - - It also adds files *inside* `fjs/types/rtti/todo/` — one at `f736b79` - (`data-validate-admits-non-djs-values.md`) — which the move would itself - relocate, so the two changes collide on the subtree as well as on - references to it. - - Whichever lands second rewrites paths the other just wrote. Cheapest order: - land the epic first, then re-run this plan's inventory over the merged tree — - the epic is prose about rtti, while this move is what invalidates paths. -- [group-fs-subdirectories-by-concern](./group-fs-subdirectories-by-concern.md) - — the umbrella reorg. This is the same shape as its item 2, which moved - `monoid` out of `types/` on the rule that `types/` admits data structures and - type-level utilities, not cross-cutting subsystems. diff --git a/fjs/types/phantom/types.ts b/fjs/types/phantom/types.ts index f33773bbe..944993881 100644 --- a/fjs/types/phantom/types.ts +++ b/fjs/types/phantom/types.ts @@ -22,13 +22,13 @@ export type { phantomKey } * * **`T` is an unchecked annotation, not a derivation** — nothing stops it from * being wrong, and once something reads it back (e.g. `Ts<>` in - * `fjs/types/rtti/ts/types.ts`, which short-circuits to `T` instead of + * `fjs/rtti/ts/types.ts`, which short-circuits to `T` instead of * structurally recursing), a wrong `T` is trusted silently. Guard every * `Phantom` with two asserts: one against the * un-annotated `rawThunk` (forces the real structural check, catching a * wrong `T`) and one against the phantom-wrapped export (catches the export * and the raw thunk drifting apart), using `Check` from - * `fjs/types/rtti/ts/types.ts` — or `Check3`, which pairs the two into one + * `fjs/rtti/ts/types.ts` — or `Check3`, which pairs the two into one * assert. See `fjs/edag/module.f.mjs` (`_exp`/`exp`) for the pattern: * * ```ts diff --git a/fjs/types/todo/66d-ts-printer-tuple-readonly-fold.md b/fjs/types/todo/66d-ts-printer-tuple-readonly-fold.md index 5f9bc5804..da342f442 100644 --- a/fjs/types/todo/66d-ts-printer-tuple-readonly-fold.md +++ b/fjs/types/todo/66d-ts-printer-tuple-readonly-fold.md @@ -56,6 +56,6 @@ touched anyway, not on its own. ### Related -- [`fjs/types/rtti/ts`](../rtti/ts/module.f.mjs) — the rtti printer consuming +- [`fjs/rtti/ts`](../../rtti/ts/module.f.mjs) — the rtti printer consuming this `Printer` (data-driven; the former i662 proposal to route it through `visit` was superseded when it stopped walking the thunk ADT at all). diff --git a/spec/todo/3360-type-annotations.md b/spec/todo/3360-type-annotations.md index 3247e08b1..f1cc9bcd2 100644 --- a/spec/todo/3360-type-annotations.md +++ b/spec/todo/3360-type-annotations.md @@ -1,7 +1,7 @@ # Type Annotations ```js -import { number, or, string } from 'functionalscript/fjs/types/rtti/module.f.mjs' +import { number, or, string } from 'functionalscript/fjs/rtti/module.f.mjs' export const myType = or(number, string) @@ -50,7 +50,7 @@ library. TypeScript's answer to typing is a superset of JavaScript with its own type grammar. JSDoc's answer is the same grammar again, only noisier. Neither is wanted here: a type should be an ordinary **value**, built from -[`fjs/types/rtti`](../../fjs/types/rtti/README.md), and an annotation should be +[`fjs/rtti`](../../fjs/rtti/README.md), and an annotation should be an ordinary **expression** naming one. `.d.ts` can be generated from the same schemas, and inference should carry as @@ -88,11 +88,11 @@ More than half of this is built: | Piece | Where | State | | --- | --- | --- | -| Schema constructors | `fjs/types/rtti/module.f.mjs` | `boolean`, `number`, `string`, `bigint`, `unknown`, `array`, `record`, `or`, `option`, `never`, plus `Const` (primitive / tuple / struct used directly as its own schema) | -| Value checking | `fjs/types/rtti/parse/` | `parse(schema)(value)` | -| Canonical data form | `fjs/types/rtti/data/` | `toData`, `cmp`, `equal`, **`subset`**, data-driven `validate` | -| TypeScript emission | `fjs/types/rtti/ts/module.f.mjs` | runtime printer: `thunk RTTI → toData → dataToTs`, emitting canonical type aliases, recursion included | -| Compile-time bridge | `Ts` in `fjs/types/rtti/ts/types.ts` | maps a schema to its TypeScript type, so `npx tsc` keeps working through the transition | +| Schema constructors | `fjs/rtti/module.f.mjs` | `boolean`, `number`, `string`, `bigint`, `unknown`, `array`, `record`, `or`, `option`, `never`, plus `Const` (primitive / tuple / struct used directly as its own schema) | +| Value checking | `fjs/rtti/parse/` | `parse(schema)(value)` | +| Canonical data form | `fjs/rtti/data/` | `toData`, `cmp`, `equal`, **`subset`**, data-driven `validate` | +| TypeScript emission | `fjs/rtti/ts/module.f.mjs` | runtime printer: `thunk RTTI → toData → dataToTs`, emitting canonical type aliases, recursion included | +| Compile-time bridge | `Ts` in `fjs/rtti/ts/types.ts` | maps a schema to its TypeScript type, so `npx tsc` keeps working through the transition | Two of these matter more than they look. `data`'s **`subset`** is assignability as a decidable operation on the canonical form — the primitive a checker needs. @@ -117,7 +117,7 @@ TypeScript aliases out. are almost entirely functions — **nearly half** the tree's JSDoc type bodies are function types (~46% when measured in review of #1719; counts drift, so re-measure rather than cite this). The schema side is tracked as - [`fjs/types/rtti/todo/668-rtti-function-types.md`](../../fjs/types/rtti/todo/668-rtti-function-types.md). + [`fjs/rtti/todo/668-rtti-function-types.md`](../../fjs/rtti/todo/668-rtti-function-types.md). > **Superseded by the epic.** An earlier draft here posed the annotation > question as a choice between "a compile-time check that cannot be @@ -243,9 +243,9 @@ annotation form and how a name resolves — rather than a paraphrase of a stage. - [rtti-type-system](../../todo/rtti-type-system.md) — the epic this document is the spec-side half of: RTTI as the sole source of truth for compile-time and run-time verification. Stages 2–5 land here. -- [`fjs/types/rtti/README.md`](../../fjs/types/rtti/README.md) — the schema system +- [`fjs/rtti/README.md`](../../fjs/rtti/README.md) — the schema system this builds on. -- [`fjs/types/rtti/todo/668-rtti-function-types.md`](../../fjs/types/rtti/todo/668-rtti-function-types.md) — +- [`fjs/rtti/todo/668-rtti-function-types.md`](../../fjs/rtti/todo/668-rtti-function-types.md) — the schema-side half of open question 3. - [type inference](./3370-type-inference.md) — the other half: annotations are only as useful as what can be inferred without them, and open question 2 below diff --git a/spec/todo/3370-type-inference.md b/spec/todo/3370-type-inference.md index ba9fabf68..a6dcd2224 100644 --- a/spec/todo/3370-type-inference.md +++ b/spec/todo/3370-type-inference.md @@ -75,7 +75,7 @@ Compared to level 2, this level contains dynamic information about subsets of th ## The inference domain: decide before designing Nothing else in stage 6 can be specified until this is settled, and once -[668](../../fjs/types/rtti/todo/668-rtti-function-types.md) lands 7a there is +[668](../../fjs/rtti/todo/668-rtti-function-types.md) lands 7a there is otherwise no task anyone can pick up to unblock 7b. - [ ] **Decide the inference domain.** Either the `enum Type` bit-set lattice @@ -95,7 +95,7 @@ otherwise no task anyone can pick up to unblock 7b. a third answer — *cannot decide* — and **today's API cannot express one**: `subset` is `(a: Data) => (b: Data) => boolean` - ([`data/module.f.mjs`](../../fjs/types/rtti/data/module.f.mjs)), so a + ([`data/module.f.mjs`](../../fjs/rtti/data/module.f.mjs)), so a `false` conflates a genuine non-inclusion with a documented undecidable case such as `readonly [number | string] ⊆ readonly [number] | readonly [string]`. @@ -103,7 +103,7 @@ otherwise no task anyone can pick up to unblock 7b. this task also owes one of: a **tri-state inclusion API**, a separate **completeness witness** saying whether a given pair falls in the decidable fragment, or completing the algorithm in the direction - [`data/README.md`](../../fjs/types/rtti/data/README.md) names. Without + [`data/README.md`](../../fjs/rtti/data/README.md) names. Without one, stage 6 cannot both reject definite type errors and fall back on incomplete ones — it has to pick a single behaviour for `false` and will be wrong for one of the two. diff --git a/todo/README.md b/todo/README.md index 2c3cd95ee..ccea5bedf 100644 --- a/todo/README.md +++ b/todo/README.md @@ -113,7 +113,7 @@ survives, or the word **`retired`** beside it with the target named: ```md - [i167](../fjs/types/bit_vec/module.f.mjs) — the `bit_vec` re-binding. -- i143 (retired; shipped as [`fjs/types/rtti/data/`](../fjs/types/rtti/data/module.f.mjs)) — … +- i143 (retired; shipped as [`fjs/rtti/data/`](../fjs/rtti/data/module.f.mjs)) — … ``` Write `retired` in the second form; it is the word that makes the resolution diff --git a/todo/edag-spec.md b/todo/edag-spec.md index e9a7a14e9..bfbbb54ee 100644 --- a/todo/edag-spec.md +++ b/todo/edag-spec.md @@ -69,7 +69,7 @@ implementation; the directory/module boundary is the important part. ### Proposal -Define the EDAG with **RTTI** ([`fjs/types/rtti`](../fjs/types/rtti/README.md)): +Define the EDAG with **RTTI** ([`fjs/rtti`](../fjs/rtti/README.md)): an RTTI schema in `fjs/edag/` is the specification of record, and Rust code for the EDAG types and the `Function` constructor's input validation/construction is **generated** from it. @@ -79,7 +79,7 @@ Why RTTI: - Single source of truth: the FJS side gets the TypeScript types (`Ts`), `validate`, and `parse` directly from the schema; the Rust side gets generated types and validation code from the same schema. -- Precedent: [`fjs/types/rtti/ts`](../fjs/types/rtti/ts/README.md) already +- Precedent: [`fjs/rtti/ts`](../fjs/rtti/ts/README.md) already prints schemas as TypeScript types; the Rust generator follows the same pattern with a Rust printer. - RTTI already supports the shapes an EDAG needs: structs, tuples, `or` @@ -120,7 +120,7 @@ standard JSON numeric policy remains separate in - [ ] Implement a Rust code generator from RTTI schemas: EDAG types + validation of the `Any` shape accepted by the `Function` constructor (following the pattern of the TypeScript printer in - [`fjs/types/rtti/ts`](../fjs/types/rtti/ts/README.md)). + [`fjs/rtti/ts`](../fjs/rtti/ts/README.md)). - [ ] Provide conformance examples (test vectors) shared by the FJS and Rust implementations. diff --git a/todo/flow.md b/todo/flow.md index a4b6254a1..7fb71d349 100644 --- a/todo/flow.md +++ b/todo/flow.md @@ -211,7 +211,7 @@ Planned engine work, each a separate change: ### Future work: RTTI and collection kinds - Replace the TypeScript-only environment with RTTI-described named inputs - (`fjs/types/rtti`), so a graph can be validated, serialized, and shipped + (`fjs/rtti`), so a graph can be validated, serialized, and shipped to a remote engine. - Add collection kinds beyond the ordered sequence: `UnorderedBag`, `Set`, ordered-per-key. Operations then declare the algebraic laws they need @@ -241,7 +241,7 @@ Planned engine work, each a separate change: - [fjs/types/function/operator/module.f.mjs](../fjs/types/function/operator/module.f.mjs) — `Scan`, `StateScan`, `Fold`: the closure-form operators `Transducer` generalizes (its JSDoc already frames them as Mealy machines) -- [fjs/types/rtti](../fjs/types/rtti) — runtime type descriptions for the +- [fjs/rtti](../fjs/rtti) — runtime type descriptions for the future named-input schema - [fjs/effects](../fjs/effects) — the same deep-embedding idea for effects: describe now, interpret later diff --git a/todo/inline-type-casts.md b/todo/inline-type-casts.md index 68a323133..0b6757732 100644 --- a/todo/inline-type-casts.md +++ b/todo/inline-type-casts.md @@ -152,7 +152,7 @@ syntax. They are deliberately left rather than rewritten. This table is an audit snapshot, and its counts above are derived from it, so rows are kept as they were recorded even when the code has since moved. The -nine `fjs/types/rtti/validate/` rows describe a module that has been deleted — +nine `fjs/rtti/validate/` rows describe a module that has been deleted — `parse` is now the only schema-form reader — so those nine sites no longer exist. Two more have since moved: `fjs/cas/evo/module.f.mjs:466`'s cast went with the flattening of `Evo.add`'s nested `Result`, and @@ -233,28 +233,28 @@ way to refresh this file, not a partial edit. | `fjs/types/nominal/proof.f.mjs` | 50 | `any` | the brand is unconstructible by design, so only an override can produce a value of this type; the cast **is** the demonstration | | `fjs/types/patricia_trie/module.f.mjs` | 49 | `readonly [typeof lastHash, typeof storage]` | arrived on `main` after the audit — not measured here | | `fjs/types/range_map/module.f.mjs` | 114 | `RangeMapArray` | cast overrides the inferred type — needs a type/API change, not a different cast | -| `fjs/types/rtti/common/module.f.mjs` | 61 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/common/module.f.mjs` | 78 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/module.f.mjs` | 27 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/module.f.mjs` | 69 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/parse/module.f.mjs` | 101 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/parse/module.f.mjs` | 103 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/parse/module.f.mjs` | 105 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/parse/module.f.mjs` | 133 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/parse/module.f.mjs` | 137 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/parse/module.f.mjs` | 159 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/parse/module.f.mjs` | 159 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/parse/module.f.mjs` | 185 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/parse/proof.f.mjs` | 30 | `T` | cast overrides the inferred type — needs a type/API change, not a different cast | -| `fjs/types/rtti/parse/proof.f.mjs` | 37 | `ValidationError` | reads an `unknown` the surrounding code has already established | -| `fjs/types/rtti/parse/proof.f.mjs` | 316 | `_A` | cast overrides the inferred type — needs a type/API change, not a different cast | -| `fjs/types/rtti/parse/proof.f.mjs` | 316 | `unknown` | no overlap without going through `unknown` — a deliberately wrong value, or a nominal brand | -| `fjs/types/rtti/validate/module.f.mjs` | 88 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/validate/module.f.mjs` | 114 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/validate/module.f.mjs` | 120 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/validate/module.f.mjs` | 140 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/validate/module.f.mjs` | 140 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/validate/module.f.mjs` | 158 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | -| `fjs/types/rtti/validate/proof.f.mjs` | 23 | `ValidationError` | reads an `unknown` the surrounding code has already established | -| `fjs/types/rtti/validate/proof.f.mjs` | 307 | `_A` | cast overrides the inferred type — needs a type/API change, not a different cast | -| `fjs/types/rtti/validate/proof.f.mjs` | 307 | `unknown` | no overlap without going through `unknown` — a deliberately wrong value, or a nominal brand | \ No newline at end of file +| `fjs/rtti/common/module.f.mjs` | 61 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/common/module.f.mjs` | 78 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/module.f.mjs` | 27 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/module.f.mjs` | 69 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/parse/module.f.mjs` | 101 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/parse/module.f.mjs` | 103 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/parse/module.f.mjs` | 105 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/parse/module.f.mjs` | 133 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/parse/module.f.mjs` | 137 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/parse/module.f.mjs` | 159 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/parse/module.f.mjs` | 159 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/parse/module.f.mjs` | 185 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/parse/proof.f.mjs` | 30 | `T` | cast overrides the inferred type — needs a type/API change, not a different cast | +| `fjs/rtti/parse/proof.f.mjs` | 37 | `ValidationError` | reads an `unknown` the surrounding code has already established | +| `fjs/rtti/parse/proof.f.mjs` | 316 | `_A` | cast overrides the inferred type — needs a type/API change, not a different cast | +| `fjs/rtti/parse/proof.f.mjs` | 316 | `unknown` | no overlap without going through `unknown` — a deliberately wrong value, or a nominal brand | +| `fjs/rtti/validate/module.f.mjs` | 88 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/validate/module.f.mjs` | 114 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/validate/module.f.mjs` | 120 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/validate/module.f.mjs` | 140 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/validate/module.f.mjs` | 140 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/validate/module.f.mjs` | 158 | `any` | `any` bridge — generic erasure with no runtime counterpart; nothing for a check to check | +| `fjs/rtti/validate/proof.f.mjs` | 23 | `ValidationError` | reads an `unknown` the surrounding code has already established | +| `fjs/rtti/validate/proof.f.mjs` | 307 | `_A` | cast overrides the inferred type — needs a type/API change, not a different cast | +| `fjs/rtti/validate/proof.f.mjs` | 307 | `unknown` | no overlap without going through `unknown` — a deliberately wrong value, or a nominal brand | \ No newline at end of file diff --git a/todo/migrate-typescript-to-mjs.md b/todo/migrate-typescript-to-mjs.md index 69defa49d..0065dce91 100644 --- a/todo/migrate-typescript-to-mjs.md +++ b/todo/migrate-typescript-to-mjs.md @@ -1094,7 +1094,7 @@ person can re-check rather than re-derive. Counts are as of [#1530](https://github.com/functionalscript/functionalscript/pull/1530) retitled it, `serializable-data.md` left when [#1539](https://github.com/functionalscript/functionalscript/pull/1539) - implemented `fjs/types/rtti/data` and deleted it, and earlier revisions + implemented `fjs/rtti/data` and deleted it, and earlier revisions of this paragraph ran one short — `fjs/emergent_testing/scenarios.md`, which quotes the deleted `run.sh` verbatim, was in the measured set but never enumerated. Review on #1530 @@ -1151,10 +1151,10 @@ person can re-check rather than re-derive. Counts are as of now [`fjs/todo/formatter-for-f-js-files.md`](../fjs/todo/formatter-for-f-js-files.md), naming `.f.mjs` (and the stage-2 `.f.js`) as the formatter's targets. - [x] **Fix the one broken doc link that is not a rename artifact.** - `fjs/types/rtti/todo/serializable-data.md` linked to `../data/module.f.ts` - before `fjs/types/rtti/data/` existed. Resolved by implementing that + `fjs/rtti/todo/serializable-data.md` linked to `../data/module.f.ts` + before `fjs/rtti/data/` existed. Resolved by implementing that issue: the module landed as authored `.f.mjs` source - ([`fjs/types/rtti/data/module.f.mjs`](../fjs/types/rtti/data/module.f.mjs)) + ([`fjs/rtti/data/module.f.mjs`](../fjs/rtti/data/module.f.mjs)) and the issue file was deleted. ### Acceptance criteria diff --git a/todo/new-pl.md b/todo/new-pl.md index 6a0efcbb2..aacc34c30 100644 --- a/todo/new-pl.md +++ b/todo/new-pl.md @@ -229,7 +229,7 @@ See [todo/blocked/js-extension-type-annotations.md](./blocked/js-extension-type- The new PL starts with type stripping: type annotations are syntax only and are erased before execution, with no built-in type checker. This keeps the core runtime simple and avoids baking in a specific type system. -In the future, type checking is provided as a library — similar to the existing [RTTI module](../fjs/types/rtti/) — that users opt into by importing it: +In the future, type checking is provided as a library — similar to the existing [RTTI module](../fjs/rtti/) — that users opt into by importing it: ```js import { check } from 'my-type-system' diff --git a/todo/plan/capl.md b/todo/plan/capl.md index 7bcddb745..2065857f4 100644 --- a/todo/plan/capl.md +++ b/todo/plan/capl.md @@ -22,7 +22,7 @@ This resolves several deep problems in modern software: **Normalization removes superficial differences.** The CA compiler normalizes code before hashing: it strips comments, whitespace, and renames internal variables to canonical forms. Two versions of a package that differ only in comments produce the same hash — they are the same package. This extends to dead code elimination: unused code that differs between versions does not affect the hash of the parts that are actually used. -Other CA languages exist — Unison is the most notable — but they require learning a new language and ecosystem from scratch. Most purely functional languages also impose a static type system (Haskell, Elm, PureScript). FunctionalScript takes a different approach: a dynamic type system at the core, with type validation as a separate, pluggable layer. TypeScript serves as the default validator today. Longer term, we plan to support additional type systems better suited to FunctionalScript's CA properties — including one based on `fjs/rtti` (runtime type information), which enables type-safe validation without requiring a compile-time type checker. The RTTI data form is implemented at [`fjs/types/rtti/data/module.f.mjs`](../../fjs/types/rtti/data/module.f.mjs); the broader universal type system design is tracked in [i141](../../fjs/types/todo/141.md). A pluggable type system means different communities can bring their own type discipline without forking the language. An RTTI-based type system has a further advantage: the same language is used for programming, for validating types, and for metaprogramming — one language, one mental model. This avoids the trap of TypeScript and similar systems, where the type layer is itself a separate, accidentally Turing-complete language (people have literally run DOOM inside the TypeScript type system). Types in FunctionalScript are ordinary FunctionalScript values and functions, not a second language bolted on top. Crucially, type annotations are erased during normalization — they do not affect the content hash of the logic. This means switching type systems never requires rewriting old algorithms: the normalized code is identical whether annotated with TypeScript types, RTTI validators, or no types at all. Old and new code remain fully compatible across type system changes. FunctionalScript is a strict subset of JavaScript: any software engineer who already knows JavaScript can read and write it immediately. The CA properties come from what FunctionalScript removes (mutation, side effects, identity-based equality) rather than from new syntax or concepts. This makes adoption frictionless for the world's largest developer community. +Other CA languages exist — Unison is the most notable — but they require learning a new language and ecosystem from scratch. Most purely functional languages also impose a static type system (Haskell, Elm, PureScript). FunctionalScript takes a different approach: a dynamic type system at the core, with type validation as a separate, pluggable layer. TypeScript serves as the default validator today. Longer term, we plan to support additional type systems better suited to FunctionalScript's CA properties — including one based on `fjs/rtti` (runtime type information), which enables type-safe validation without requiring a compile-time type checker. The RTTI data form is implemented at [`fjs/rtti/data/module.f.mjs`](../../fjs/rtti/data/module.f.mjs); the broader universal type system design is tracked in [i141](../../fjs/types/todo/141.md). A pluggable type system means different communities can bring their own type discipline without forking the language. An RTTI-based type system has a further advantage: the same language is used for programming, for validating types, and for metaprogramming — one language, one mental model. This avoids the trap of TypeScript and similar systems, where the type layer is itself a separate, accidentally Turing-complete language (people have literally run DOOM inside the TypeScript type system). Types in FunctionalScript are ordinary FunctionalScript values and functions, not a second language bolted on top. Crucially, type annotations are erased during normalization — they do not affect the content hash of the logic. This means switching type systems never requires rewriting old algorithms: the normalized code is identical whether annotated with TypeScript types, RTTI validators, or no types at all. Old and new code remain fully compatible across type system changes. FunctionalScript is a strict subset of JavaScript: any software engineer who already knows JavaScript can read and write it immediately. The CA properties come from what FunctionalScript removes (mutation, side effects, identity-based equality) rather than from new syntax or concepts. This makes adoption frictionless for the world's largest developer community. FunctionalScript's purely functional, side-effect-free design makes it an ideal foundation for a CA language: without mutation or identity-based equality, normalization is well-defined and deduplication is always safe. diff --git a/todo/retired-issue-identifiers.md b/todo/retired-issue-identifiers.md index 71173839a..1d4286175 100644 --- a/todo/retired-issue-identifiers.md +++ b/todo/retired-issue-identifiers.md @@ -140,7 +140,7 @@ correctly-resolved citation as outstanding. A resolution takes one of two forms: ```md - [i167](../fjs/types/bit_vec/module.f.mjs) — the identifier as a link label, where a document survives to link to. -- i143 (retired; shipped as [`fjs/types/rtti/data/`](../fjs/types/rtti/data/module.f.mjs)) +- i143 (retired; shipped as [`fjs/rtti/data/`](../fjs/rtti/data/module.f.mjs)) — the identifier with `retired` beside it and the target named, for code. - the retired `i171` … resolved **won't fix**, reason in `parseTestSet`'s JSDoc. ``` diff --git a/todo/rtti-type-system.md b/todo/rtti-type-system.md index e3fbda700..0f9e220de 100644 --- a/todo/rtti-type-system.md +++ b/todo/rtti-type-system.md @@ -11,14 +11,14 @@ file; this one is where they are read together. A type in this repository is written more than once. The same shape is a JSDoc `@typedef`, a declaration in a sibling `types.ts`, and — where a value has to be -checked at run time — an [RTTI](../fjs/types/rtti/README.md) schema. Nothing +checked at run time — an [RTTI](../fjs/rtti/README.md) schema. Nothing keeps the three in agreement: `tsc` checks the first two against the code and the third against nothing, so a schema and its `@typedef` drift silently, and the drift shows up as a value that type-checks and fails validation, or the reverse. The bridge that exists runs the wrong way. `Ts` -([`fjs/types/rtti/ts/README.md`](../fjs/types/rtti/ts/README.md)) maps a schema +([`fjs/rtti/ts/README.md`](../fjs/rtti/ts/README.md)) maps a schema to its TypeScript type, which makes a schema usable *from* TypeScript, and it pays for it: `TS2589` on recursive schemas, a `WithOut` phantom annotation to escape the walk, and three classes of `as any` cast that the README documents as @@ -43,7 +43,7 @@ Five commitments make that concrete. There is **no type language to invent**. A type is an ordinary expression — written in the language, in a `const`, never in a comment — built from -[`fjs/types/rtti/module.f.mjs`](../fjs/types/rtti/module.f.mjs) — +[`fjs/rtti/module.f.mjs`](../fjs/rtti/module.f.mjs) — `boolean`, `number`, `string`, `bigint`, `unknown`, `array`, `record`, `or`, `option`, `never`, `rest`, `open`, plus `Const` (a primitive, tuple, or struct used directly as its own schema, closed). It is a value: it can be named, imported, @@ -72,7 +72,7 @@ type grammar", which is the thing this project exists to avoid primitives, `array`, `record`, `or`, `option`, `never`, `rest`, `open`, and consts. It does not yet say functions -([668](../fjs/types/rtti/todo/668-rtti-function-types.md)) or brands +([668](../fjs/rtti/todo/668-rtti-function-types.md)) or brands ([134](./134-nominal-types-proposal.md)), and it will need to say more than that. Because a type is a *value*, each of those is a new exported function in a module — not a keyword, not a grammar production, not a tokenizer change, and @@ -126,7 +126,7 @@ Anything more than a name is written as an ordinary `const` first, in the language, where it already belongs: ```js -import { array, number, option, or, string } from 'functionalscript/fjs/types/rtti/module.f.mjs' +import { array, number, option, or, string } from 'functionalscript/fjs/rtti/module.f.mjs' const key = or(number, string) const keys = array(key) @@ -233,7 +233,7 @@ RTTI cannot describe a mutable value, and this is a feature rather than a missing one. FunctionalScript values are immutable, and the eDSL has no way to spell a writable member: `Ts` renders every struct member, array, record, and tuple as `readonly` -([`ts/types.ts`](../fjs/types/rtti/ts/types.ts)), because there is no other +([`ts/types.ts`](../fjs/rtti/ts/types.ts)), because there is no other thing for it to render. That is the difference between this checker and TypeScript's, and it is not a @@ -256,7 +256,7 @@ the program above compiles. **None of that arises here.** A schema denotes a *set of immutable values*; `subset` is inclusion between two such sets, approximated soundly on the canonical -[`data`](../fjs/types/rtti/data/module.f.mjs) form, with no writer anywhere to +[`data`](../fjs/rtti/data/module.f.mjs) form, with no writer anywhere to make the answer go stale. Three concrete consequences — the first two holding **within FunctionalScript**, for the reason the next paragraph is careful about: @@ -286,7 +286,7 @@ caller. Verify-then-mutate is closed by the language, not by the reader. `validate` should not close it by freezing or copying: returning the value it was given *is* its contract — a content-addressed document's bytes are its identity, so a reconstruction is a different document -([`rtti/README.md`](../fjs/types/rtti/README.md)) — and freezing the caller's +([`rtti/README.md`](../fjs/rtti/README.md)) — and freezing the caller's object would be a mutation of it. The reader for a value arriving from outside the boundary is `parse`, which constructs a fresh value holding only what the schema declares; the README already assigns it that role, "the reader for a @@ -295,7 +295,7 @@ value coming *in* — from JSON, from a protocol frame". **`parse` is the boundary only where the schema names every part.** It constructs a fresh container, but `unknown` is `() => ok` and `ok` returns the value it was handed -([`parse`](../fjs/types/rtti/parse/module.f.mjs)), so a value admitted through +([`parse`](../fjs/rtti/parse/module.f.mjs)), so a value admitted through an `unknown` — the whole schema, or one field of a struct — comes back as the caller's own object, aliases intact. `parse(unknown)(obj)` *is* `obj`. So the advice "use `parse` and hold the result" holds for a schema with no `unknown` @@ -438,7 +438,7 @@ document already establishes, and `.f.mjs` modules keep their JSDoc and their An npm package ships `.d.ts` so that TypeScript consumers see types; the declarations are **generated from the schemas**, never authored. -[`fjs/types/rtti/ts/module.f.mjs`](../fjs/types/rtti/ts/module.f.mjs) is already +[`fjs/rtti/ts/module.f.mjs`](../fjs/rtti/ts/module.f.mjs) is already that printer — `thunk RTTI → toData → dataToTs`, emitting canonical aliases with recursion handled — so this is close to plumbing plus an `fjs` command, and it is the stage that can land earliest. Not *only* plumbing, though, and not entirely on @@ -468,7 +468,7 @@ rejected by `validate`. That is the exact disagreement this epic exists to remove, surviving inside its own deliverable. (The *tuple* kind has no such gap: a TypeScript tuple is exact-length, so `Ts<>` renders a closed tuple exactly — which is what stage 1 of -[option-as-omission](../fjs/types/rtti/todo/option-as-omission.md) settled.) +[option-as-omission](../fjs/rtti/todo/option-as-omission.md) settled.) Two things keep this from undermining the whole direction, and both need stating rather than assuming: @@ -499,7 +499,7 @@ stating rather than assuming: TypeScript requires an index signature to cover the declared keys too, so the printer widens the index type "to the union of the rest and the declared value types — the closest expressible supertype" -([`rtti/ts`](../fjs/types/rtti/ts/module.f.mjs)). `rest({ a: number }, string)` +([`rtti/ts`](../fjs/rtti/ts/module.f.mjs)). `rest({ a: number }, string)` therefore emits an index of `number | string`, and a caller may pass `{ a: 1, b: 2 }` — a numeric extra key, which the schema rejects because its rest is `string`. Note that an exact-key encoding for the bare, closed form @@ -511,11 +511,11 @@ that type" as two separate constraints. how often they appear. The printer renders a numeric const as `isFinite(c) ? String(c) : 'number'` ([`fjs/types/ts`](../fjs/types/ts/module.f.mjs), which -[`rtti/ts`](../fjs/types/rtti/ts/module.f.mjs) imports), so a `NaN`, +[`rtti/ts`](../fjs/rtti/ts/module.f.mjs) imports), so a `NaN`, `Infinity`, or `-Infinity` const becomes the type `number`, and `-0` becomes the literal `0`. Validation meanwhile uses `Object.is` **on purpose** — its doc comment says so, precisely to match `NaN` and to keep `+0` and `-0` distinct -([`rtti/common`](../fjs/types/rtti/common/module.f.mjs)). So a `NaN` schema in +([`rtti/common`](../fjs/rtti/common/module.f.mjs)). So a `NaN` schema in an exported input position admits any number and rejects all but one, and a `-0` schema admits `+0` and rejects it. TypeScript has no `NaN` literal type and does not distinguish `-0` from `0`, so this is inexpressible in the same @@ -553,15 +553,15 @@ it as scoped to the object shapes TypeScript can name. | Piece | Where | State | | --- | --- | --- | -| Schema constructors | [`fjs/types/rtti/module.f.mjs`](../fjs/types/rtti/module.f.mjs) | done | -| Run-time checking | [`parse/`](../fjs/types/rtti/parse/module.f.mjs), [`validate/`](../fjs/types/rtti/validate/module.f.mjs) | done — same acceptance, differing only in what a success carries. `data`'s reader is **not** a third with the same acceptance: see [data-validate-admits-non-djs-values](../fjs/types/rtti/todo/data-validate-admits-non-djs-values.md) | -| Canonical data form, `subset` | [`data/`](../fjs/types/rtti/data/module.f.mjs) | done, and **sound but deliberately incomplete** — it never answers `true` for a non-inclusion, and may answer `false` for one that holds only semantically. The primitive a checker needs, not the whole of assignability | -| TypeScript emission | [`ts/module.f.mjs`](../fjs/types/rtti/ts/module.f.mjs) | done as a printer — but it and `Ts<>` disagree on `unknown` and on tuple openness, by its own doc comment, so it is not yet a faithful `.d.ts` generator | -| Compile-time bridge | `Ts` in [`ts/types.ts`](../fjs/types/rtti/ts/types.ts) | done, and transitional — see Problem | +| Schema constructors | [`fjs/rtti/module.f.mjs`](../fjs/rtti/module.f.mjs) | done | +| Run-time checking | [`parse/`](../fjs/rtti/parse/module.f.mjs), [`validate/`](../fjs/rtti/validate/module.f.mjs) | done — same acceptance, differing only in what a success carries. `data`'s reader is **not** a third with the same acceptance: see [data-validate-admits-non-djs-values](../fjs/rtti/todo/data-validate-admits-non-djs-values.md) | +| Canonical data form, `subset` | [`data/`](../fjs/rtti/data/module.f.mjs) | done, and **sound but deliberately incomplete** — it never answers `true` for a non-inclusion, and may answer `false` for one that holds only semantically. The primitive a checker needs, not the whole of assignability | +| TypeScript emission | [`ts/module.f.mjs`](../fjs/rtti/ts/module.f.mjs) | done as a printer — but it and `Ts<>` disagree on `unknown` and on tuple openness, by its own doc comment, so it is not yet a faithful `.d.ts` generator | +| Compile-time bridge | `Ts` in [`ts/types.ts`](../fjs/rtti/ts/types.ts) | done, and transitional — see Problem | | Annotation syntax | — | not started | | Compile-time evaluation | [`fjs/fsc/todo/47.md`](../fjs/fsc/todo/47.md) | not started | | Inference | [type inference](../spec/todo/3370-type-inference.md) | not started — most of the work | -| Function schemas | [668-rtti-function-types](../fjs/types/rtti/todo/668-rtti-function-types.md) | not started — and **nearly half** the tree's JSDoc type bodies are function types (~46% when measured in review of #1719; counts drift, so re-measure rather than cite this), so it gates a large share of stage 11 | +| Function schemas | [668-rtti-function-types](../fjs/rtti/todo/668-rtti-function-types.md) | not started — and **nearly half** the tree's JSDoc type bodies are function types (~46% when measured in review of #1719; counts drift, so re-measure rather than cite this), so it gates a large share of stage 11 | | Generic schemas | the eDSL itself | **value layer done** — a schema-to-schema function needs no feature; only `.d.ts` / `Ts<>` rendering is missing | More than half the run-time and emission side is built. The compile-time side is @@ -658,7 +658,7 @@ An annotation-only import is exactly that shape. So, stated honestly: the cases that rule rejects, and the epic owes it a resolution rather than an assumption — [stage 12](#tasks); - the resolution is anchoring, not an exemption for schema modules. That - `fjs/types/rtti/module.f.mjs` has no throwing top-level computation is true + `fjs/rtti/module.f.mjs` has no throwing top-level computation is true and is not a rule; a schema can be imported from anywhere. Two further qualifications. This is a property of the FunctionalScript compiler @@ -807,7 +807,7 @@ are stated instead: first diagnostic. - [ ] **1. `.d.ts` generation from schemas.** An `fjs` command over - [`ts/module.f.mjs`](../fjs/types/rtti/ts/module.f.mjs), wired into + [`ts/module.f.mjs`](../fjs/rtti/ts/module.f.mjs), wired into packaging ([publishing-packages](../fjs/ci/todo/publishing-packages.md)). No compiler work and no language change — but **not just a command**. The printer's own doc comment records two divergences from `Ts<>`. They @@ -816,8 +816,8 @@ are stated instead: - **`unknown` — the printer matches the runtime; `Ts<>` is the narrow one.** Both readers implement the `unknown` case as `() => ok` - ([`validate`](../fjs/types/rtti/validate/module.f.mjs), - [`parse`](../fjs/types/rtti/parse/module.f.mjs)), so the schema as + ([`validate`](../fjs/rtti/validate/module.f.mjs), + [`parse`](../fjs/rtti/parse/module.f.mjs)), so the schema as *executed* accepts anything, functions and symbols included. The printer's TypeScript `unknown` says the same. It is `Ts<>` that maps to the DJS-shaped `Primitive | Array | Object` and so promises less than @@ -833,11 +833,11 @@ are stated instead: same exact tuple. `open(c)` is what admits a longer array, and both renderers emit the tail that says so. This bullet used to record a live divergence and no longer does; stage 1 of - [option-as-omission](../fjs/types/rtti/todo/option-as-omission.md) + [option-as-omission](../fjs/rtti/todo/option-as-omission.md) removed it. **A third disagreement runs the other way, and has narrowed.** `RestTs` - ([`ts/types.ts`](../fjs/types/rtti/ts/types.ts)) now renders a stated + ([`ts/types.ts`](../fjs/rtti/ts/types.ts)) now renders a stated rest's tuple tail, so the two agree on every rest a schema states directly. What is left is the *empty*-rest recognition: the printer goes through the data form and recognizes one semantically, while `RestTs` @@ -857,14 +857,14 @@ are stated instead: stage 11's, under its rule about reproducing what was published. **The `unknown` meaning question needs an owner with a gate, not just a - home.** Its natural home is [`rtti`](../fjs/types/rtti/README.md) rather + home.** Its natural home is [`rtti`](../fjs/rtti/README.md) rather than this epic — but an earlier draft stopped there, and a question deferred without a gate is one stage 11 can walk straight past. Four sources disagree about what an exported `unknown` promises: the module and its README say DJS-compatible values; `Ts<>` excludes functions and symbols; the readers accept them - ([`validate`](../fjs/types/rtti/validate/module.f.mjs) and - [`parse`](../fjs/types/rtti/parse/module.f.mjs) both have + ([`validate`](../fjs/rtti/validate/module.f.mjs) and + [`parse`](../fjs/rtti/parse/module.f.mjs) both have `unknown: () => ok`); and the printer emits TypeScript's unrestricted `unknown`. Until one is chosen, an exported `unknown` has no settled published meaning — so **stage 11 cannot retire a declaration containing @@ -891,7 +891,7 @@ are stated instead: **And it is not fully independent of the compiler stages.** The printer renders *a schema* to a type expression; - [`dataToTs`](../fjs/types/rtti/ts/module.f.mjs) returns aliases plus that + [`dataToTs`](../fjs/rtti/ts/module.f.mjs) returns aliases plus that expression, not `export const ks: …`. Generating a module's `.d.ts` also needs to know **which export has which schema**, and that association is exactly what an annotation supplies — which does not exist until stages @@ -1005,7 +1005,7 @@ are stated instead: **And the snapshot has to survive being written down.** Reusing the compile-time schema at run time means embedding it in the shipped program, which means serializing it — and - [`data/README.md`](../fjs/types/rtti/data/README.md) states the corner + [`data/README.md`](../fjs/rtti/data/README.md) states the corner itself: JSON's number model "writes a `NaN` literal member as `null` and drops `-0`'s sign, so a schema using those two as literal members does not round-trip textually today and needs a serializer that preserves @@ -1039,7 +1039,7 @@ are stated instead: **no `parse`**, and no `Data`-to-`Type` reconstruction anywhere in the tree. So a snapshot can stabilize a `validate` call and cannot stabilize a `parse` one: - [`parse`](../fjs/types/rtti/parse/module.f.mjs) takes the thunk-form + [`parse`](../fjs/rtti/parse/module.f.mjs) takes the thunk-form `Type` and walks it, re-entering the thunk, while handing it the `Data` instead would change its signature. That is the reader this epic leans on hardest — stage 13's inbound remedy is "`parse` against a schema that @@ -1099,7 +1099,7 @@ are stated instead: run-time disagreement for another. Nor is the repair a one-liner: for `unknown` the two readers *agree*, and agree only because of the same fall-through, so guarding it produces the mirror-image divergence. Filed as - [data-validate-admits-non-djs-values](../fjs/types/rtti/todo/data-validate-admits-non-djs-values.md), + [data-validate-admits-non-djs-values](../fjs/rtti/todo/data-validate-admits-non-djs-values.md), which this stage is gated on. **It does not reach function schemas if stage 7a goes extern.** `data` is @@ -1123,7 +1123,7 @@ are stated instead: pipeline uses. Stage 4's remedy only holds if every later phase reads the *snapshot*, so this stage reads it too — which makes it depend on the `data` reader divergence being fixed first - ([data-validate-admits-non-djs-values](../fjs/types/rtti/todo/data-validate-admits-non-djs-values.md)), + ([data-validate-admits-non-djs-values](../fjs/rtti/todo/data-validate-admits-non-djs-values.md)), since checking against the snapshot means checking with `data`'s reader. Requiring schema purity instead is the alternative, and it is the same choice stage 4 already records — decided once, for both stages. @@ -1131,7 +1131,7 @@ are stated instead: **It needs the schema itself checked first, and nothing yet does that.** `visit` assumes its input already satisfies the static `Type` contract: an unrecognized tag falls through to `v.primitive0(tag)` - ([`rtti/common`](../fjs/types/rtti/common/module.f.mjs)), so a binding + ([`rtti/common`](../fjs/rtti/common/module.f.mjs)), so a binding whose value is `() => ['wat']` is reducible, callable, and behaves as a type that rejects everything — rather than producing the "that is not a schema" compile error stage 4 owes. An always-failing type is the worst @@ -1160,7 +1160,7 @@ are stated instead: expressions first and widened afterwards. Say which; do not leave the order implied by the numbering. **A `false` from `subset` is not a type error.** It is - [sound and deliberately incomplete](../fjs/types/rtti/data/README.md#subset-is-sound-and-deliberately-incomplete): + [sound and deliberately incomplete](../fjs/rtti/data/README.md#subset-is-sound-and-deliberately-incomplete): it never says `true` wrongly, but it says `false` for inclusions that hold only semantically — `readonly [number | string] ⊆ readonly [number] | readonly [string]` is the documented case, along with non-syntactically @@ -1174,7 +1174,7 @@ are stated instead: the direction `data/README.md` names (semantic subtyping, CDuce-style). Deciding that is part of the stage, not a detail under it. - [ ] **7. Function schemas** - ([668-rtti-function-types](../fjs/types/rtti/todo/668-rtti-function-types.md)), + ([668-rtti-function-types](../fjs/rtti/todo/668-rtti-function-types.md)), and what an annotation on a function *means*. An earlier draft posed that as two choices — a compile-time check that cannot be completed, or a wrapper validating each call — and **that is a false choice**, inherited @@ -1245,7 +1245,7 @@ are stated instead: **Adding the schema form is necessary and not sufficient**, because everything downstream of it runs on the canonical `data` form, and that form is *function-free* by construction - ([`data/README.md`](../fjs/types/rtti/data/README.md)). Stage 6 checks + ([`data/README.md`](../fjs/rtti/data/README.md)). Stage 6 checks through `data`'s `subset`; stage 1's printer goes `toData → dataToTs`. 668 itself contemplates an **extern** form that "may need to remain outside that core form" — and a schema outside it has no assignability @@ -1488,7 +1488,7 @@ are stated instead: `RangeError: Maximum call stack size exceeded`; - `parse` flattens shared references, changing the hash of a value whose sharing is part of its meaning - ([identity-aware-parse](../fjs/types/rtti/todo/identity-aware-parse.md), + ([identity-aware-parse](../fjs/rtti/todo/identity-aware-parse.md), which already owns this); - `validate` costs time exponential in sharing depth, which that same issue classifies as a DoS vector against untrusted input. Its recorded @@ -1658,7 +1658,7 @@ splits around inference, so the runnable order is 668's representation half edit. - [type inference](../spec/todo/3370-type-inference.md) — annotations are only as useful as what can be inferred without them. Stage 6. -- [668-rtti-function-types](../fjs/types/rtti/todo/668-rtti-function-types.md) — +- [668-rtti-function-types](../fjs/rtti/todo/668-rtti-function-types.md) — RTTI cannot describe a function today, and FunctionalScript modules are almost entirely functions. Stage 7, and the first real test of "growing the eDSL is library work". @@ -1668,7 +1668,7 @@ splits around inference, so the runnable order is 668's representation half - [141](../fjs/types/todo/141.md) — the earlier, more abstract form of this idea: a `TypeSystem` interface with `equal`/`subset`, and a parser recognizing `Ts`. `subset` shipped in - [`rtti/data`](../fjs/types/rtti/data/module.f.mjs); the parser half is this + [`rtti/data`](../fjs/rtti/data/module.f.mjs); the parser half is this epic. - [types-for-fs.md](./types-for-fs.md) — why TypeScript's own type system is not the target: it cannot analyze mutable types soundly, which is the argument @@ -1735,12 +1735,12 @@ splits around inference, so the runnable order is 668's representation half changed by it. - [publishing-packages](../fjs/ci/todo/publishing-packages.md) — consumes stage 1's generated `.d.ts`. -- [`fjs/types/rtti/ts/README.md`](../fjs/types/rtti/ts/README.md) — not an issue, +- [`fjs/rtti/ts/README.md`](../fjs/rtti/ts/README.md) — not an issue, but the record of what `Ts` costs and why stage 11 exists. - [rtti-parse](../fjs/media/json/todo/rtti-parse.md) — reading JSON text straight against a schema; the run-time side continuing to grow around the same source of truth. -- [identity-aware-parse](../fjs/types/rtti/todo/identity-aware-parse.md) — +- [identity-aware-parse](../fjs/rtti/todo/identity-aware-parse.md) — neither reader tracks input identity, so `validate` re-walks a shared subgraph once per incoming edge and costs time *exponential in sharing depth* (a 19-array value at 509ms, ~14s by depth 22). Two limits on what that means @@ -1754,10 +1754,10 @@ splits around inference, so the runnable order is 668's representation half stage 5 is under. For [stage 13](#tasks) it is the filed DoS itself, because "validate at entry" points this reader at untrusted public input, and that policy is gated on the issue. -- [checked-const-pin](../fjs/types/rtti/todo/checked-const-pin.md) — how a +- [checked-const-pin](../fjs/rtti/todo/checked-const-pin.md) — how a schema bound to a `const` pins its literal; open, no design agreed. It is the ergonomics of commitment 2's "write it as a `const` first". -- [excluded-string-values](../fjs/types/rtti/todo/excluded-string-values.md) — +- [excluded-string-values](../fjs/rtti/todo/excluded-string-values.md) — `Type` has no negation, so a set like "any string but these" is unsayable. A gap in the eDSL of exactly the kind commitment 1 says gets closed there. - [`fjs/protocol/json_rpc`](../fjs/protocol/json_rpc/module.f.mjs) and diff --git a/todo/tsconfig-strict-flags.md b/todo/tsconfig-strict-flags.md index 7e23dc99f..6f417af55 100644 --- a/todo/tsconfig-strict-flags.md +++ b/todo/tsconfig-strict-flags.md @@ -18,7 +18,7 @@ every index access yield `T | undefined`, which is exactly the obligation by an unchecked cast. Its error sites cluster in the same modules where [inline-type-casts.md](./inline-type-casts.md) already found `assert` candidates: `fjs/effects/node/virtual/`, `fjs/bnf/descent/`, -`fjs/types/rtti/data/`, `fjs/sul/level/hash/`. +`fjs/rtti/data/`, `fjs/sul/level/hash/`. ### Measurements diff --git a/todo/types-for-fs.md b/todo/types-for-fs.md index c887fe70e..792ce03dc 100644 --- a/todo/types-for-fs.md +++ b/todo/types-for-fs.md @@ -83,7 +83,7 @@ const ap: A5 = { p: 5, x: 6 } f(ap) // compilation error. ``` -We may have a special version of TypeScript and it should have a run-time description, similar to [RTTI](../fjs/types/rtti/README.md) +We may have a special version of TypeScript and it should have a run-time description, similar to [RTTI](../fjs/rtti/README.md) ## Benefits From e62e4cea1a44328a548f39910e5adfdb4915c1db Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:25:31 +0000 Subject: [PATCH 011/370] Add changelog entry for the rtti move Names it a breaking change per changelog/README.md's mechanical version-bump trigger: functionalscript/fjs/types/rtti/... importers need the types/ segment dropped. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EJuNsKf9jpgULV67jFo2sy --- changelog/unreleased/1736.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/unreleased/1736.md diff --git a/changelog/unreleased/1736.md b/changelog/unreleased/1736.md new file mode 100644 index 000000000..b07f95f4b --- /dev/null +++ b/changelog/unreleased/1736.md @@ -0,0 +1,4 @@ +- **BREAKING CHANGES:** `fjs/types/rtti/` moved to `fjs/rtti/` — it is a peer + of `djs`, not a member of `types/`. No code changed, only import paths; + every importer inside and outside the repo needs the `types/` segment + dropped from `functionalscript/fjs/types/rtti/…`. From 8ddd2f2188105109182b51d30c951bbc4c5308e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:51:18 +0000 Subject: [PATCH 012/370] Fix rtti link missed by the move in fjs/types/todo/141.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fjs/types/todo/141.md:34 spelled the path as `rtti/data` without a `types/` segment, so a `types/rtti` grep didn't catch it when the rest of the move's link fixes ran. Re-anchor it from `../rtti/…` to `../../rtti/…` now that rtti lives one level further from types/todo/. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EJuNsKf9jpgULV67jFo2sy --- fjs/types/todo/141.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fjs/types/todo/141.md b/fjs/types/todo/141.md index 4139493be..bf1e368e5 100644 --- a/fjs/types/todo/141.md +++ b/fjs/types/todo/141.md @@ -31,5 +31,5 @@ How it should work: ### Related - [rtti-type-system](../../../todo/rtti-type-system.md) — the epic this became: - `subset` shipped in [`rtti/data`](../rtti/data/module.f.mjs), and the parser + `subset` shipped in [`rtti/data`](../../rtti/data/module.f.mjs), and the parser half is stage 3 there. From 01f29ad7fe03934a0844206d1c89738def0709a4 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 07:27:09 +0000 Subject: [PATCH 013/370] emergent_testing: one proof runner for `fjs t` and the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser runner and `fjs t` implemented the same proof semantics twice — leaf discovery, tree walking, the structural `throw` expectation, promise resolution, path formatting, counting — in `emergent_testing/browser.mjs` and `emergent_testing/module.f.mjs`, and had begun to drift. They are now one runner. `runModuleMap` is the single source of truth; a host supplies a `Reporter` and an effect interpreter and nothing else. - `fjs/effects/common/` holds the operations no host owns — `all`, `await`, `fetch`, `import`, `now`, `sandbox` and the `IoError` helpers — moved out of `fjs/effects/node/`, which re-exports every one of them unchanged. - `fjs/effects/browser/module.mjs` interprets exactly that set against a browser realm, and takes the composed runner so a page can add operations of its own. - `emergent_testing`'s new `report`/`reported` operations and `recordingReporter` normalize each leaf into a `TestResult` carrying no terminal text and no DOM. - `emergent_testing/browser/module.f.mjs` is the pure browser application — link, run, report — provable from Node with a stand-in interpreter; `emergent_testing/browser/module.mjs` is left with the DOM, the published promise and the completion event. Verified end to end in Chromium: 3435 proofs, all passing, rendered and published from the generated page. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/README.md | 25 + fjs/effects/browser/module.mjs | 123 +++++ fjs/effects/common/module.f.mjs | 217 ++++++++ fjs/effects/common/proof.f.mjs | 168 ++++++ fjs/effects/common/types.ts | 143 ++++++ fjs/effects/memory/types.ts | 2 +- fjs/effects/node/module.f.mjs | 216 +------- fjs/effects/node/proof.f.mjs | 75 +-- fjs/effects/node/types.ts | 130 +---- fjs/emergent_testing/README.md | 44 ++ fjs/emergent_testing/browser.mjs | 455 ---------------- fjs/emergent_testing/browser/module.f.mjs | 140 +++++ fjs/emergent_testing/browser/module.mjs | 177 +++++++ fjs/emergent_testing/browser/proof.f.mjs | 162 ++++++ fjs/emergent_testing/browser/proof.mjs | 484 ++++++------------ .../browser/species.proof.mjs | 45 -- fjs/emergent_testing/browser/types.ts | 74 +++ fjs/emergent_testing/module.f.mjs | 97 +++- fjs/emergent_testing/proof.f.mjs | 62 ++- .../todo/browser-test-controls.md | 4 +- fjs/emergent_testing/todo/browser-testing.md | 9 +- .../todo/hostile-proof-values.md | 62 +++ .../todo/share-browser-console-runner.md | 146 ------ fjs/emergent_testing/types.ts | 43 +- fjs/website/module.f.mjs | 2 +- fjs/website/todo/generate-website.md | 2 +- .../todo/website-preparation-program.md | 69 +++ 27 files changed, 1809 insertions(+), 1367 deletions(-) create mode 100644 fjs/effects/browser/module.mjs create mode 100644 fjs/effects/common/module.f.mjs create mode 100644 fjs/effects/common/proof.f.mjs create mode 100644 fjs/effects/common/types.ts delete mode 100644 fjs/emergent_testing/browser.mjs create mode 100644 fjs/emergent_testing/browser/module.f.mjs create mode 100644 fjs/emergent_testing/browser/module.mjs create mode 100644 fjs/emergent_testing/browser/proof.f.mjs delete mode 100644 fjs/emergent_testing/browser/species.proof.mjs create mode 100644 fjs/emergent_testing/browser/types.ts create mode 100644 fjs/emergent_testing/todo/hostile-proof-values.md delete mode 100644 fjs/emergent_testing/todo/share-browser-console-runner.md create mode 100644 fjs/website/todo/website-preparation-program.md diff --git a/fjs/effects/README.md b/fjs/effects/README.md index 9cc4cd487..6049fb864 100644 --- a/fjs/effects/README.md +++ b/fjs/effects/README.md @@ -144,6 +144,31 @@ conflated in either direction — a capability the runner merely lacks is answer with `NotImplemented`, never by killing the program, and a refusal to continue is an interruption, never dressed up as `NotImplemented`. +## Where an operation lives + +An operation belongs to the host that alone can perform it, and to +[`./common/`](./common/module.f.mjs) when no host owns it. `all`, `await`, +`fetch`, `import`, `now` and `sandbox` describe what a JavaScript *realm* can do +— hold a value, wait for a promise, measure a call, link a module — so the Node +runner, the browser runner and the virtual runner each implement the same +command at the same contract. `readFile`, `write`, `exec`, `createServer` and +`test` describe what a *host* can do, and stay in [`./node/`](./node/types.ts). + +The line is not bookkeeping. It is what lets a program state that it needs +nothing host-specific and then be run by either host: the browser proof runner +(`fjs/emergent_testing/browser/module.f.mjs`) performs only `CommonOp` plus two +operations of its own, which is why it and `fjs t` can share every line of proof +semantics between them. `./node/` re-exports every common name, so a consumer +that already imports one module for `readFile` keeps importing it for `sandbox`. + +An interpreter lives beside the host it interprets — [`./node/module.mjs`](./node/module.mjs), +[`./browser/module.mjs`](./browser/module.mjs) — and the browser one implements +`CommonOp` and nothing else. There is no browser filesystem and no browser +stdout, and inventing spellings for them would describe a host that does not +exist; a page that needs an operation of its own composes its handlers on top of +that map, which is why `browserOperationMap` takes the composed runner rather +than closing over one of its own. + ## Leaving the layer Not every consumer is ready to compose. Two named policies exist so that a site diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs new file mode 100644 index 000000000..9d8e1f2b2 --- /dev/null +++ b/fjs/effects/browser/module.mjs @@ -0,0 +1,123 @@ +/** + * Browser effect runner: interprets the host-independent operations + * (`../common/types.ts`) against a browser realm. + * + * It is the browser's counterpart of [`../node/module.mjs`](../node/module.mjs) + * and deliberately implements **only** `CommonOp`. There is no browser + * filesystem, no subprocess and no stdout to interpret, and inventing browser + * spellings for those would describe a host that does not exist; a page needing + * something of its own — a DOM to render into, a report to publish — composes + * its handlers on top of this map rather than finding them in it. + * + * The module has no Node dependency of any kind, so a page links it as an + * ordinary ES module with no bundling or transpilation. + * + * @module + * + * @import { Effect, ToAsyncOperationMap } from '../types.ts' + * @import { Result } from '../../types/result/types.ts' + * @import { CommonOp, Module, SandboxResult } from '../common/types.ts' + * @import { IoResult } from '../common/types.ts' + */ + +import { toIoError } from '../common/module.f.mjs' +import { error, ok } from '../../types/result/module.f.mjs' +import { asyncTryCatch } from '../../types/result/module.mjs' +import { toVec } from '../../types/uint8array/module.f.mjs' + +/** + * An effect runner over the operations this map is spread into. `all` runs its + * children through it rather than through a runner of its own, so an effect + * nested inside `all` reaches every handler the caller composed — not just the + * common ones. + * + * @typedef {(effect: Effect) => Promise>} CommonRun + */ + +/** + * Links a module in the page's realm. Injected so a caller can report loading + * progress, resolve a specifier against an application root, or drive the + * runner from a proof without a network; the default is the realm's own + * dynamic `import`. + * + * @typedef {(source: string) => Promise} BrowserImporter + */ + +/** + * Performs host IO, reporting a thrown failure as an {@link IoResult} error. + * + * The browser twin of the Node runner's `io`: the one place where an exception + * becomes ordinary effect data, normalized so nothing past it sees the thrown + * object. + * + * @template T + * @param {() => Promise} f + * @returns {Promise>} + */ +const io = async f => { + const r = await asyncTryCatch(f) + return r[0] === 'ok' ? r : error(toIoError(r[1])) +} + +/** + * Runs `f` and measures it, exactly as the Node runner does: a genuine + * `Promise` is awaited and a rejection is caught, and any other value — a proof + * tree carrying a `then` property included — is the result as it stands. + * + * That equality is the point. `fjs t` and this runner walk the same proof trees + * through the same shared semantics (`fjs/emergent_testing/module.f.mjs`), so + * the one operation that actually *executes* a proof body has to agree with its + * Node counterpart or the two runners disagree about what a suite means. + * + * @template T + * @param {() => T} f + * @returns {Promise>} + */ +const sandbox = async f => { + /** @type {Result} */ + let result + let after + const before = performance.now() + try { + let p = f() + after = performance.now() + if (p instanceof Promise) { + p = await p + after = performance.now() + } + result = ok(p) + } catch (e) { + after = performance.now() + result = error(e) + } + return { result, duration: after - before } +} + +/** + * The browser's handlers for the host-independent operations. + * + * `run` is the composed runner the caller builds — the one that also knows the + * caller's own operations — so `all` schedules its children through it. Passing + * it in rather than closing over a runner defined here is what keeps this map + * composable: a page adds handlers, and the effects nested inside `all` still + * reach them. + * + * @type {(run: CommonRun, importer?: BrowserImporter) => ToAsyncOperationMap} + */ +export const browserOperationMap = (run, importer = source => import(source)) => ({ + all: async (...effects) => ok(await Promise.all(effects.map(e => run(e)))), + await: async p => ok([p instanceof Promise ? await p : p]), + fetch: url => io(async () => { + const response = await globalThis.fetch(url) + if (!response.ok) { + throw new Error(`Fetch error: ${response.status} ${response.statusText}`) + } + return toVec(new Uint8Array(await response.arrayBuffer())) + }), + // A synchronous throw from the importer — a specifier the realm rejects + // before it ever starts loading — is a load failure like any other, so it + // is caught here rather than escaping the effect it belongs to. + import: path => io(async () => importer(path)), + now: async () => ok(Date.now()), + sandbox: async f => ok(await sandbox(f)), +}) diff --git a/fjs/effects/common/module.f.mjs b/fjs/effects/common/module.f.mjs new file mode 100644 index 000000000..a513e58db --- /dev/null +++ b/fjs/effects/common/module.f.mjs @@ -0,0 +1,217 @@ +/** + * The operations no host owns, and the helpers that read their error channel. + * + * `all` / `allOk` / `both` (concurrency), `await` (promise resolution), + * `fetch`, `import_`, `now` and `sandbox` each describe something a JavaScript + * realm can do on its own, so every runner implements them the same way: the + * Node runner in [`../node/module.mjs`](../node/module.mjs), the browser runner + * in [`../browser/module.mjs`](../browser/module.mjs), and the virtual one in + * [`../node/virtual/module.f.mjs`](../node/virtual/module.f.mjs). + * + * They lived in `../node/module.f.mjs`, which re-exports every name below so an + * existing importer keeps naming one module. What is genuinely Node's — the + * filesystem, streams, subprocesses, HTTP, an external test framework — stayed + * there. + * + * See [`./types.ts`](./types.ts) for the type-level API. + * + * @module + * + * @import { Effect, Func, NotImplemented, Operation } from '../types.ts' + * @import { Result } from '../../types/result/types.ts' + * @import { All, Await, Fetch, Import, IoChannel, IoError, IoErrorInfo, Now, Sandbox } from './types.ts' + */ + +import { do_, mapStep, pure, step } from '../module.f.mjs' +import { ok as resultOk, unwrap } from '../../types/result/module.f.mjs' + +/** + * Builds a normalized host error. The constructor exists so the shape is + * written once: every runner reports its failures through it, and a consumer + * matching on `'ioError'` knows what the payload holds. + * + * @type {(info: IoErrorInfo) => IoError} + */ +export const ioError = info => ['ioError', info] + +/** + * Normalizes a **thrown** value into an {@link IoError}: the OS error code when + * the host attached a string one, and a message that is the `Error`'s own or + * the value's string form. + * + * This is the boundary where an impure runner's `catch` becomes ordinary effect + * data. Nothing past it sees the thrown object, which is the point — a stack, a + * `cause`, and arbitrary own properties do not survive a wire hop, and a + * program that branched on them would be reading the host's implementation + * rather than the operation's contract. + * + * @type {(e: unknown) => IoError} + */ +export const toIoError = e => { + const message = e instanceof Error ? e.message : String(e) + if (typeof e !== 'object' || e === null || !('code' in e) || typeof e.code !== 'string') { + return ioError({ message }) + } + return ioError({ code: e.code, message }) +} + +/** + * True if `e` is a "file or directory does not exist" (`ENOENT`) error. + * + * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which + * {@link toIoError} keeps; the virtual interpreter reports the same code for + * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh + * store) while propagating genuine failures (permissions, corruption) rather + * than masking them. + * + * A {@link NotImplemented} is never "not found": a runner that cannot perform + * the operation has not looked for the path at all, so the two must not + * collapse into one benign branch — which is exactly what a bare `unknown` + * error channel used to allow. + * + * @type {(e: IoChannel) => boolean} + */ +export const isNotFound = ([tag, payload]) => + tag === 'ioError' && payload.code === 'ENOENT' + +/** + * Renders a channel error as a human line: an {@link IoError}'s own message, or + * the command name a runner could not dispatch. + * + * @type {(e: IoChannel) => string} + */ +export const errorMessage = ([tag, payload]) => + tag === 'notImplemented' ? `operation not implemented: ${payload}` : payload.message + +/** + * Renders a channel error for a **remote** caller: the command name for a + * {@link NotImplemented}, the OS error code for an `IoError`, and nothing else. + * + * {@link errorMessage} is for the operator of the program, who is entitled to + * the host's own words — including the path that failed. A protocol client is + * not, and the difference is not stylistic: `payload.message` is where the + * host puts the absolute path it could not read, so answering an MCP tool call + * with it publishes the server's filesystem layout to whoever is on the other + * end. The code (`ENOENT`, `EACCES`) says *what* went wrong without saying + * *where*, which is the part a client can act on anyway. + * + * A host that attached no code leaves nothing safe to forward, so the answer is + * the bare kind. That is deliberate: guessing which part of a free-text message + * is path-free is exactly the mistake this exists to prevent. + * + * @type {(e: IoChannel) => string} + */ +export const errorSummary = ([tag, payload]) => + tag === 'notImplemented' + ? `operation not implemented: ${payload}` + : payload.code === undefined ? 'io error' : `io error: ${payload.code}` + +// all + +/** + * To run the operation `O` should be known by the runner/engine. + * This is the reason why we merge `O` with `All` in the resulting effect. + */ +export const all = + // `Func` cannot express a variadic generic operation, so the declared type + // is written out here and `do_`'s is set aside. + /** @type {(...a: readonly Effect[]) => Effect[], NotImplemented>} */ + (/** @type {unknown} */ (do_('all'))) + +/** + * Collapses a list of results into a result of the list, keeping the **first** + * error in list order and discarding the later ones. + * + * Keeping one is what makes this a `Result` rather than a report: the callers + * that need it are chains, and a chain has one error channel. A site that wants + * every failure wants a different return type and should not reach for this. + * + * @type {(list: readonly Result[]) => Result} + */ +const okList = list => { + for (const r of list) { + if (r[0] === 'error') { return r } + } + return resultOk(list.map(unwrap)) +} + +/** + * {@link all} in the `ok` channel: collects the values when every effect + * succeeded, and answers with the first failure otherwise. + * + * `all` alone cannot serve a fallible chain. Its envelope is the runner's + * (`OpResult`, saying whether the *operation* could be dispatched), so handing + * it `Effect`s nests one `Result` inside another and the caller receives + * `readonly Result[]`. That has to be collapsed before the chain can + * `step` again, and a continuation that forgets to is the value-discarding + * hazard this migration exists to remove — one level in, where it is harder to + * see. + * + * **Every effect still runs.** The short-circuit is in the *result*, not in the + * execution: `all` performs them concurrently and this reads the answers once + * they are all in, so a failure does not cancel its siblings the way it stops + * the sequential `forEachStep` in `../module.f.mjs`. The error channel + * unions the runner's + * `NotImplemented` with the effects' own `E` for the same reason every other + * step does — either can be what went wrong. + * + * @type {(...a: readonly Effect[]) => Effect} + */ +export const allOk = (...a) => + step(all(...a), rs => pure(okList(rs))) + +/** + * @template {Operation} O0 + * @template T0 + * @template E0 + * @param {Effect} a + * @returns {(b: Effect) => Effect, Result], NotImplemented>} + */ +export const both = a => b => + /** @type {any} */ (all)(a, b) + +// fetch + +/** @type {Func} */ +export const fetch = do_('fetch') + +// import + +/** @type {Func} */ +export const import_ = do_('import') + +// now + +/** @type {Func} */ +export const now = do_('now') + +// sandbox + +/** + * Runs a plain synchronous function in an isolated, measured environment. + * + * Combines try/catch and high-resolution timing into a single atomic operation. + * Only plain synchronous functions are accepted — no effects, no promises. + * + * Using a single operation rather than separate `TryCatch` + `Perf` effects is + * necessary for correctness: effects execute as async tasks, so the scheduler + * can insert arbitrary work between two separate timing calls, making the + * measured delta inaccurate. Here the clock reads happen synchronously around + * the function call with nothing in between. + * + * Future parameters (time limit, memory limit) can be added to the payload + * without breaking the API. Worker-based implementations can enforce hard + * limits via worker termination. + * + * @see {@link SandboxResult} + * + * @type {Func} + */ +export const sandbox = do_('sandbox') + +/** @type {Func} */ +const awaitPromise = do_('await') + +/** @type {(p: unknown) => Effect} */ +export const awaitIfPromise = p => + mapStep(awaitPromise(p), ([x]) => x) diff --git a/fjs/effects/common/proof.f.mjs b/fjs/effects/common/proof.f.mjs new file mode 100644 index 000000000..1ea0435a9 --- /dev/null +++ b/fjs/effects/common/proof.f.mjs @@ -0,0 +1,168 @@ +/** + * Proofs for the host-independent operations and the helpers that read their + * error channel. + * + * The operations are proved against a stand-in interpreter declared here rather + * than against a host runner: what this module owns is the *constructors* and + * the `ok`-channel collapse, and a proof that reached for `../node/virtual` + * would be reading a Node runner's answers to decide whether `all` builds the + * right node. Each host runner proves its own handlers — `../node/proof.f.mjs` + * for the virtual and Node ones, `../../emergent_testing/browser/proof.mjs` for + * the browser one. + * + * @import { Effect } from '../types.ts' + * @import { Result } from '../../types/result/types.ts' + * @import { MemOperationMap, RunInstance } from '../mock/types.ts' + * @import { CommonOp, SandboxResult } from './types.ts' + */ + +import { assert, assertEq } from '../../asserts/module.f.mjs' +import { + all, allOk, awaitIfPromise, both, errorMessage, errorSummary, fetch, import_, + ioError, isNotFound, now, sandbox, toIoError, +} from './module.f.mjs' +import { run as mockRun } from '../mock/module.f.mjs' +import { error, ok, unwrap } from '../../types/result/module.f.mjs' +import { vec8 } from '../../types/bit_vec/module.f.mjs' + +/** The one number the stand-in clock ever answers. */ +const fixedNow = 1_700_000_000 + +/** @type {MemOperationMap} */ +const map = { + all: (...a) => state => [state, ok(a.map(i => common(state)(i)[1]))], + await: p => state => [state, ok([p])], + fetch: url => state => [ + state, + url === 'ok' ? ok(vec8(0x2An)) : error(ioError({ message: `cannot fetch ${url}` })), + ], + import: source => state => [ + state, + source === 'ok' ? ok({ value: 1 }) : error(ioError({ code: 'ENOENT', message: source })), + ], + now: () => state => [state, ok(fixedNow)], + // The same pass-through the virtual Node runner uses: a fixture returns the + // `SandboxResult` it wants reported, so an outcome is dictated rather than + // measured. + sandbox: f => state => [state, ok(/** @type {SandboxResult} */ (f()))], +} + +/** @type {RunInstance} */ +const common = mockRun(map) + +/** @type {(e: Effect) => Result} */ +const run = e => common(null)(e)[1] + +export const proof = { + // The one boundary where a runner's `catch` becomes effect data: whatever + // was thrown is reduced to a code (when the host attached a string one) + // and a message. + toIoError: { + error: () => { + assertEq(toIoError(new Error('boom'))[1].message, 'boom') + }, + withCode: () => { + const [, info] = toIoError(Object.assign(new Error('missing'), { code: 'ENOENT' })) + assertEq(info.code, 'ENOENT') + assertEq(info.message, 'missing') + }, + // A thrown non-`Error` still normalizes: the value's string form is the + // message, and there is no code to carry. + string: () => { + const [, info] = toIoError('plain') + assertEq(info.code, undefined) + assertEq(info.message, 'plain') + }, + null: () => { + assertEq(toIoError(null)[1].message, 'null') + }, + // An object whose `code` is not a string is not an OS error code, so it + // is dropped rather than carried as one. + nonStringCode: () => { + assertEq(toIoError({ code: 42 })[1].code, undefined) + }, + noCode: () => { + assertEq(toIoError({})[1].code, undefined) + }, + }, + isNotFound: { + enoent: () => { + assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) + }, + otherCode: () => { + assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) + }, + // A runner that cannot perform the operation has not looked for the + // path at all, so a missing handler is never "not found". + notImplemented: () => { + assert(!isNotFound(['notImplemented', 'readFile'])) + }, + }, + errorMessage: { + io: () => { + assertEq(errorMessage(ioError({ message: 'disk full' })), 'disk full') + }, + notImplemented: () => { + assertEq(errorMessage(['notImplemented', 'readFile']), 'operation not implemented: readFile') + }, + }, + errorSummary: { + // The distinction that matters: `errorMessage` hands back the host's + // words, which is where the path lives; `errorSummary` never does. + io: () => { + assertEq(errorSummary(ioError({ code: 'ENOENT', message: "no such file or directory, scandir '/home/u/.cas'" })), 'io error: ENOENT') + }, + ioWithoutCode: () => { + assertEq(errorSummary(ioError({ message: "cannot read '/home/u/.cas'" })), 'io error') + }, + notImplemented: () => { + assertEq(errorSummary(['notImplemented', 'readdir']), 'operation not implemented: readdir') + }, + }, + // `all` answers each effect's whole `Result`: its own envelope says only + // whether the operation could be dispatched. + all: () => { + const r = unwrap(run(all(fetch('ok'), fetch('no')))) + assertEq(r.length, 2) + assertEq(r[0]?.[0], 'ok') + assertEq(r[1]?.[0], 'error') + }, + allOk: { + // The collapse a fallible chain wants: values when every effect + // succeeded... + collects: () => { + assertEq(unwrap(run(allOk(now(), now()))).join(','), `${fixedNow},${fixedNow}`) + }, + // ...and the first failure in list order otherwise. + firstError: () => { + const r = run(allOk(fetch('no'), fetch('worse'))) + assert(r[0] === 'error', r) + assertEq(errorMessage(r[1]), 'cannot fetch no') + }, + }, + both: () => { + const [a, b] = unwrap(run(both(now())(import_('ok')))) + assertEq(unwrap(a ?? error(0)), fixedNow) + assertEq(unwrap(b ?? error(0)).value, 1) + }, + import: { + linked: () => { + assertEq(unwrap(run(import_('ok'))).value, 1) + }, + missing: () => { + const r = run(import_('nope')) + assert(r[0] === 'error', r) + assert(isNotFound(r[1]), r[1]) + }, + }, + sandbox: () => { + const { result, duration } = unwrap(run(sandbox(() => ({ result: ok(7), duration: 3 })))) + assertEq(unwrap(result), 7) + assertEq(duration, 3) + }, + // A promise is the runner's business, so what the constructor owns is + // unwrapping the one-element tuple the operation answers with. + awaitIfPromise: () => { + assertEq(unwrap(run(awaitIfPromise(5))), 5) + }, +} diff --git a/fjs/effects/common/types.ts b/fjs/effects/common/types.ts new file mode 100644 index 000000000..e4acbc550 --- /dev/null +++ b/fjs/effects/common/types.ts @@ -0,0 +1,143 @@ +/** + * Types for the operations no host owns. + * + * Every operation declared here describes something a JavaScript realm can do + * on its own — hold a value, wait for a promise, measure a call, link a module, + * fetch a URL — so a Node runner, a browser runner, and the virtual runner can + * each implement the same command with the same contract. What is genuinely + * Node's — streams, the filesystem, subprocesses, an external test framework — + * stays in [`../node/types.ts`](../node/types.ts), which re-exports these so an + * existing importer keeps naming one module. + * + * @module + */ + +import type { Vec } from '../../types/bit_vec/types.ts' +import type { Effect, NotImplemented } from '../types.ts' +import type { Result } from '../../types/result/types.ts' +import type { StringMap } from '../../types/object/types.ts' + +/** + * A host failure, normalized: whatever the runtime threw reduced to a + * serializable record. `code` is the OS error code when the host supplied one + * (`'ENOENT'`, `'EEXIST'`), absent otherwise. + * + * It is a tagged tuple for the same reason {@link NotImplemented} is — the two + * share an error channel, and the tag is what tells them apart. That + * distinction is the whole reason this type exists: with a bare `unknown` + * error, `NotImplemented | unknown` collapses to `unknown` and a program can no + * longer tell "this runner cannot do it" from "the host tried and failed". + * + * Normalizing also keeps the channel serializable. A thrown `Error` carries a + * stack, a `cause`, and arbitrary own properties; none of it survives a wire + * hop, and a runner in another process could not reproduce it. + */ +export type IoError = readonly['ioError', IoErrorInfo] + +export type IoErrorInfo = { + readonly code?: string + readonly message: string +} + +/** + * The result of an operation with no failures of its own: it either produces + * its value or reports that the runner does not implement it. + * + * Every operation's return type is a `Result`, including the ones that cannot + * fail on their own terms — an operation left on a raw contract would be a hole + * in the error channel, and a runner may omit a handler for any of them. + */ +export type OpResult = Result + +/** + * The error channel of anything that performs host IO: a normalized host + * failure, or the report that the runner does not implement the operation. + * + * It is one name rather than a union spelled at each site, and that is a + * migration property rather than brevity. An effect that does no IO *yet* is + * one added `readFile` away from doing some, and if each signature names its + * own errors, that one change walks up every enclosing signature — the failure + * mode that sank `throws` clauses elsewhere, where engineers eventually + * declared everything throwing rather than maintain the cascade. Declaring the + * standard channel once is that concession made deliberately: an IO-touching + * effect says it fails *the way node IO fails*, and gaining a new way to do so + * changes nothing above it. + * + * It is not a licence to widen. An operation with failures of its own extends + * the channel (`IoChannel | ParseError`), and a computation whose errors are + * genuinely narrower should say so — this is the default for IO, not a ceiling. + */ +export type IoChannel = NotImplemented | IoError + +/** + * The result of an operation that performs host IO: its value, a normalized + * host failure, or the missing-handler report. + */ +export type IoResult = Result + +// all + +/** + * Runs its effects concurrently and answers each one's whole `Result`. + * + * The nesting is deliberate and belongs to the runner: this envelope says + * whether `all` itself could be dispatched, and each inner `Result` is what + * that effect answered. `allOk` (`./module.f.mjs`) is the collapse a fallible + * chain wants. + */ +export type All = ['all', (...effects: Effect[]) => OpResult[]>] + +// fetch + +export type Fetch = ['fetch', (url: string) => IoResult] + +// import + +export type Module = StringMap + +export type Import = ['import', (path: string) => IoResult] + +// now + +export type Now = readonly['now', () => OpResult] + +// sandbox + +/** + * The outcome of a `Sandbox` operation. + * + * `result` carries either `['ok', value]` or `['error', thrown]`. `duration` + * is a floating-point millisecond count with up to microsecond precision, + * matching `performance.now()` directly. Additional fields (allocated memory, + * max stack depth, coverage) may be added in future without breaking consumers. + */ +export type SandboxResult = { + readonly result: Result + /** + * Elapsed time in milliseconds (microsecond precision via `performance.now()`). + * The virtual runner returns `0` for deterministic tests. + */ + readonly duration: number +} + +export type Sandbox = readonly['sandbox', (f: () => T) => OpResult>] + +/** + * Resolves the return value of a test function inside the effect runner. + * If `p` is a real `Promise`, it is awaited and rejections propagate as + * throws. If `p` is any other value it is returned as-is. Plain thenables + * (objects with a `.then` method that are not `instanceof Promise`) are + * treated as ordinary values — not awaited. See `fjs/dev/tf/README.md`. + */ +export type Await = readonly['await', (p: unknown) => OpResult] + +/** + * The operations every runner is expected to be able to implement. + * + * A host runner's operation set is this union plus whatever its host adds: + * `NodeOp` is `CommonOp | MemOp | Fs | Http | …`, and the browser interpreter + * in [`../browser/module.mjs`](../browser/module.mjs) implements exactly this + * set against the browser realm. Naming it once is what lets a program say it + * needs nothing host-specific, and be run by either. + */ +export type CommonOp = All | Await | Fetch | Import | Now | Sandbox diff --git a/fjs/effects/memory/types.ts b/fjs/effects/memory/types.ts index 844dbb80c..cc72052a8 100644 --- a/fjs/effects/memory/types.ts +++ b/fjs/effects/memory/types.ts @@ -6,7 +6,7 @@ import type { Phantom } from '../../types/phantom/types.ts' import type { Nominal } from '../../types/nominal/types.ts' -import type { OpResult } from '../node/types.ts' +import type { OpResult } from '../common/types.ts' /** Nominal brand version for memory keys. */ export type _MemKeyHash = '3f114fa6036a8da026b827f0c3e6d901f5e81ad9a320e431ccce31451892d286' diff --git a/fjs/effects/node/module.f.mjs b/fjs/effects/node/module.f.mjs index 5d1c6dddd..50344ea11 100644 --- a/fjs/effects/node/module.f.mjs +++ b/fjs/effects/node/module.f.mjs @@ -1,10 +1,14 @@ /** * Node.js effect operations: filesystem (`mkdir`, `readFile`, `readdir`, * `writeFile`, `rm`, `access`, plus the `readUtf8File`/`writeUtf8File` text - * helpers), networking (`fetch`, `createServer`, `listen`), - * subprocess `exec`, `log`/`error` (wrappers over `write`), `import_`, `now`, - * `sandbox`, `forever`, and `all`/`both` parallelism; defines the - * `NodeOp`/`NodeProgram` types used by the Node runner. + * helpers), HTTP (`createServer`, `listen`), subprocess `exec`, `log`/`error` + * (wrappers over `write`), `read`/`readLine`, `randomInt` and `forever`; defines + * the `NodeOp`/`NodeProgram` types used by the Node runner. + * + * The operations no host owns — `all`/`allOk`/`both`, `await`, `fetch`, + * `import_`, `now`, `sandbox`, and the `IoError` helpers — moved to + * [`../common/module.f.mjs`](../common/module.f.mjs) so the browser runner can + * link them, and are re-exported here unchanged. * * See `./types.ts` for the type-level API. * @@ -14,7 +18,8 @@ * @import { Result } from '../../types/result/types.ts' * @import { Commands, CommandSet, Effect, Func, NotImplemented, Operation } from '../types.ts' * @import { List } from '../list/types.ts' - * @import { All, Access, Await, Console, CreateExclusive, CreateServer, Dirent, Engine, Env, Exec, ExecResult, Fetch, FileStat, Forever, Fs, Headers, Http, IncomingMessage, Import, IoChannel, IoError, IoErrorInfo, Listen, MakeDirectoryOptions, Mkdir, Module, Now, NodeOp, NodeProgramOptions, RandomInt, Read, ReadBytes, ReadConsoles, ReadFile, Readdir, ReaddirOptions, RequestListener, Rename, Rm, Sandbox, SandboxResult, Server, ServerResponse, Stat, Test, TestContext, TestFn, Write, WriteBytes, WriteConsoles, WriteFile, _UtfList, _WriteLoop } from './types.ts' + * @import { IoError } from '../common/types.ts' + * @import { All, Access, Console, CreateExclusive, CreateServer, Dirent, Engine, Env, Exec, ExecResult, FileStat, Forever, Fs, Headers, Http, IncomingMessage, IoChannel, Listen, MakeDirectoryOptions, Mkdir, NodeOp, NodeProgramOptions, RandomInt, Read, ReadBytes, ReadConsoles, ReadFile, Readdir, ReaddirOptions, RequestListener, Rename, Rm, SandboxResult, Server, ServerResponse, Stat, Test, TestContext, TestFn, Write, WriteBytes, WriteConsoles, WriteFile, _UtfList, _WriteLoop } from './types.ts' */ import { utf8, utf8ToString } from '../../text/module.f.mjs' @@ -22,20 +27,23 @@ import { toCodePointList } from '../../text/utf8/module.f.mjs' import { codePointListToString } from '../../text/utf16/module.f.mjs' import { reverse } from '../../types/list/module.f.mjs' import { length } from '../../types/bit_vec/module.f.mjs' -import { error as resultError, ok as resultOk, unwrap } from '../../types/result/module.f.mjs' -import { do_, pure } from '../module.f.mjs' +import { error as resultError } from '../../types/result/module.f.mjs' +import { do_ } from '../module.f.mjs' import { mapStep as ioMapStep, pureError, pureOk, resultMapStep, resultStep, step as ioStep, } from '../module.f.mjs' +import { errorMessage, ioError } from '../common/module.f.mjs' /** - * Builds a normalized host error. The constructor exists so the shape is - * written once: every runner reports its failures through it, and a consumer - * matching on `'ioError'` knows what the payload holds. - * - * @type {(info: IoErrorInfo) => IoError} + * The host-independent operations, re-exported so a caller that already names + * this module for `readFile` keeps naming it for `sandbox` and `all` too. They + * are defined in [`../common/module.f.mjs`](../common/module.f.mjs), which the + * browser runner links without reaching a Node type. */ -export const ioError = info => ['ioError', info] +export { + all, allOk, awaitIfPromise, both, errorMessage, errorSummary, fetch, import_, + ioError, isNotFound, now, sandbox, toIoError, +} from '../common/module.f.mjs' /** * The host a {@link Listen} refuses. @@ -83,46 +91,6 @@ export const emptyHostError = ioError({ message: emptyHostMessage, }) -/** - * Normalizes a **thrown** value into an {@link IoError}: the OS error code when - * the host attached a string one, and a message that is the `Error`'s own or - * the value's string form. - * - * This is the boundary where an impure runner's `catch` becomes ordinary effect - * data. Nothing past it sees the thrown object, which is the point — a stack, a - * `cause`, and arbitrary own properties do not survive a wire hop, and a - * program that branched on them would be reading the host's implementation - * rather than the operation's contract. - * - * @type {(e: unknown) => IoError} - */ -export const toIoError = e => { - const message = e instanceof Error ? e.message : String(e) - if (typeof e !== 'object' || e === null || !('code' in e) || typeof e.code !== 'string') { - return ioError({ message }) - } - return ioError({ code: e.code, message }) -} - -/** - * True if `e` is a "file or directory does not exist" (`ENOENT`) error. - * - * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which - * {@link toIoError} keeps; the virtual interpreter reports the same code for - * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh - * store) while propagating genuine failures (permissions, corruption) rather - * than masking them. - * - * A {@link NotImplemented} is never "not found": a runner that cannot perform - * the operation has not looked for the path at all, so the two must not - * collapse into one benign branch — which is exactly what a bare `unknown` - * error channel used to allow. - * - * @type {(e: IoChannel) => boolean} - */ -export const isNotFound = ([tag, payload]) => - tag === 'ioError' && payload.code === 'ENOENT' - /** * `NodeOp`'s commands as data, so a runner that implements only part of them * can still tell an operation it lacks from a `Do` node whose `command` was @@ -155,75 +123,6 @@ const nodeCommandSet = { */ export const nodeCommands = /** @type {Commands} */ (Object.keys(nodeCommandSet)) -// all - -/** - * To run the operation `O` should be known by the runner/engine. - * This is the reason why we merge `O` with `All` in the resulting effect. - */ -export const all = - // `Func` cannot express a variadic generic operation, so the declared type - // is written out here and `do_`'s is set aside. - /** @type {(...a: readonly Effect[]) => Effect[], NotImplemented>} */ - (/** @type {unknown} */ (do_('all'))) - -/** - * Collapses a list of results into a result of the list, keeping the **first** - * error in list order and discarding the later ones. - * - * Keeping one is what makes this a `Result` rather than a report: the callers - * that need it are chains, and a chain has one error channel. A site that wants - * every failure wants a different return type and should not reach for this. - * - * @type {(list: readonly Result[]) => Result} - */ -const okList = list => { - for (const r of list) { - if (r[0] === 'error') { return r } - } - return resultOk(list.map(unwrap)) -} - -/** - * {@link all} in the `ok` channel: collects the values when every effect - * succeeded, and answers with the first failure otherwise. - * - * `all` alone cannot serve a fallible chain. Its envelope is the runner's - * (`OpResult`, saying whether the *operation* could be dispatched), so handing - * it `Effect`s nests one `Result` inside another and the caller receives - * `readonly Result[]`. That has to be collapsed before the chain can - * `step` again, and a continuation that forgets to is the value-discarding - * hazard this migration exists to remove — one level in, where it is harder to - * see. - * - * **Every effect still runs.** The short-circuit is in the *result*, not in the - * execution: `all` performs them concurrently and this reads the answers once - * they are all in, so a failure does not cancel its siblings the way it stops - * the sequential `forEachStep` in `./module.f.mjs`. The error channel - * unions the runner's - * `NotImplemented` with the effects' own `E` for the same reason every other - * step does — either can be what went wrong. - * - * @type {(...a: readonly Effect[]) => Effect} - */ -export const allOk = (...a) => - ioStep(all(...a), rs => pure(okList(rs))) - -/** - * @template {Operation} O0 - * @template T0 - * @template E0 - * @param {Effect} a - * @returns {(b: Effect) => Effect, Result], NotImplemented>} - */ -export const both = a => b => - /** @type {any} */ (all)(a, b) - -// fetch - -/** @type {Func} */ -export const fetch = do_('fetch') - // mkdir /** @type {Func} */ @@ -356,11 +255,6 @@ export const listen = do_('listen') /** @type {Func} */ export const forever = do_('forever') -// import - -/** @type {Func} */ -export const import_ = do_('import') - // write /** Emits a `Write` effect to the given named stream. */ @@ -430,42 +324,6 @@ export const readLine = stream => { return loop(null) } -// now - -/** @type {Func} */ -export const now = do_('now') - -// sandbox - -/** - * Runs a plain synchronous function in an isolated, measured environment. - * - * Combines try/catch and high-resolution timing into a single atomic operation. - * Only plain synchronous functions are accepted — no effects, no promises. - * - * Using a single operation rather than separate `TryCatch` + `Perf` effects is - * necessary for correctness: effects execute as async tasks, so the scheduler - * can insert arbitrary work between two separate timing calls, making the - * measured delta inaccurate. Here the clock reads happen synchronously around - * the function call with nothing in between. - * - * Future parameters (time limit, memory limit) can be added to the payload - * without breaking the API. Worker-based implementations can enforce hard - * limits via worker termination. - * - * @see {@link SandboxResult} - * - * @type {Func} - */ -export const sandbox = do_('sandbox') - -/** @type {Func} */ -const awaitPromise = do_('await') - -/** @type {(p: unknown) => Effect} */ -export const awaitIfPromise = p => - ioMapStep(awaitPromise(p), ([x]) => x) - // Test registration /** @type {Func} */ @@ -512,38 +370,6 @@ export const errorExit = s => */ export const exitCode = ([, code]) => code -/** - * Renders a channel error as a human line: an {@link IoError}'s own message, or - * the command name a runner could not dispatch. - * - * @type {(e: IoChannel) => string} - */ -export const errorMessage = ([tag, payload]) => - tag === 'notImplemented' ? `operation not implemented: ${payload}` : payload.message - -/** - * Renders a channel error for a **remote** caller: the command name for a - * {@link NotImplemented}, the OS error code for an `IoError`, and nothing else. - * - * {@link errorMessage} is for the operator of the program, who is entitled to - * the host's own words — including the path that failed. A protocol client is - * not, and the difference is not stylistic: `payload.message` is where the - * host puts the absolute path it could not read, so answering an MCP tool call - * with it publishes the server's filesystem layout to whoever is on the other - * end. The code (`ENOENT`, `EACCES`) says *what* went wrong without saying - * *where*, which is the part a client can act on anyway. - * - * A host that attached no code leaves nothing safe to forward, so the answer is - * the bare kind. That is deliberate: guessing which part of a free-text message - * is path-free is exactly the mistake this exists to prevent. - * - * @type {(e: IoChannel) => string} - */ -export const errorSummary = ([tag, payload]) => - tag === 'notImplemented' - ? `operation not implemented: ${payload}` - : payload.code === undefined ? 'io error' : `io error: ${payload.code}` - /** * Ends a program with an exit code that reflects `e`: `ok` yields `0`, and a * failure is reported on `stderr` and yields `1` ({@link errorExit}). diff --git a/fjs/effects/node/proof.f.mjs b/fjs/effects/node/proof.f.mjs index e04035da3..6c31b7f19 100644 --- a/fjs/effects/node/proof.f.mjs +++ b/fjs/effects/node/proof.f.mjs @@ -10,7 +10,7 @@ import { empty, isVec, uint, vec, vec8 } from "../../types/bit_vec/module.f.mjs" import { utf8, utf8ToString } from "../../text/module.f.mjs" import { match } from "../module.f.mjs" import { mapStep, step as ioStep } from "../module.f.mjs" -import { both, errorMessage, errorSummary, exitStep, fetch, ioError, isNotFound, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, toIoError, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" +import { both, exitStep, fetch, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" import { create as memCreate, read as memRead, write as memWrite } from "../memory/module.f.mjs" import { empty as listEmpty, nonEmpty as listNonEmpty } from "../list/module.f.mjs" import { emptyState, virtual } from "./virtual/module.f.mjs" @@ -50,77 +50,8 @@ const assertOk = (r, expected) => { } export const proof = { - // The one boundary where a runner's `catch` becomes effect data: whatever - // was thrown is reduced to a code (when the host attached a string one) - // and a message. - toIoError: { - error: () => { - assertIoMessage(toIoError(new Error('boom')), 'boom') - }, - withCode: () => { - const e = toIoError(Object.assign(new Error('missing'), { code: 'ENOENT' })) - assert(e[0] === 'ioError', e) - assertEq(e[1].code, 'ENOENT', e) - assertEq(e[1].message, 'missing', e) - }, - // A thrown non-`Error` still normalizes: the value's string form is the - // message, and there is no code to carry. - string: () => { - const e = toIoError('plain') - assert(e[0] === 'ioError', e) - assertEq(e[1].code, undefined, e) - assertEq(e[1].message, 'plain', e) - }, - null: () => { - assertIoMessage(toIoError(null), 'null') - }, - // An object whose `code` is not a string is not an OS error code, so it - // is dropped rather than carried as one. - nonStringCode: () => { - const e = toIoError({ code: 42 }) - assert(e[0] === 'ioError', e) - assertEq(e[1].code, undefined, e) - }, - noCode: () => { - const e = toIoError({}) - assert(e[0] === 'ioError', e) - assertEq(e[1].code, undefined, e) - }, - }, - isNotFound: { - enoent: () => { - assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) - }, - otherCode: () => { - assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) - }, - // A runner that cannot perform the operation has not looked for the - // path at all, so a missing handler is never "not found". - notImplemented: () => { - assert(!isNotFound(['notImplemented', 'readFile'])) - }, - }, - errorMessage: { - io: () => { - assertEq(errorMessage(ioError({ message: 'disk full' })), 'disk full') - }, - notImplemented: () => { - assertEq(errorMessage(['notImplemented', 'readFile']), 'operation not implemented: readFile') - }, - }, - errorSummary: { - // The distinction that matters: `errorMessage` hands back the host's - // words, which is where the path lives; `errorSummary` never does. - io: () => { - assertEq(errorSummary(ioError({ code: 'ENOENT', message: "no such file or directory, scandir '/home/u/.cas'" })), 'io error: ENOENT') - }, - ioWithoutCode: () => { - assertEq(errorSummary(ioError({ message: "cannot read '/home/u/.cas'" })), 'io error') - }, - notImplemented: () => { - assertEq(errorSummary(['notImplemented', 'readdir']), 'operation not implemented: readdir') - }, - }, + // `toIoError`, `isNotFound`, `errorMessage` and `errorSummary` are proved + // in `../common/proof.f.mjs`, beside the module that now defines them. exitStep: { // The exit-code policy a `NodeProgram` ends with: success is `0`... ok: () => { diff --git a/fjs/effects/node/types.ts b/fjs/effects/node/types.ts index 886a045f3..459c745d8 100644 --- a/fjs/effects/node/types.ts +++ b/fjs/effects/node/types.ts @@ -6,86 +6,23 @@ import type { List as EffectList } from '../../types/list/types.ts' import type { Vec } from '../../types/bit_vec/types.ts' +import type { All, Await, CommonOp, IoChannel, IoResult, OpResult } from '../common/types.ts' import type { MemOp } from '../memory/types.ts' import type { Nominal } from '../../types/nominal/types.ts' -import type { Result } from '../../types/result/types.ts' import type { StringMap } from '../../types/object/types.ts' -import type { Effect, NotImplemented, Operation, ToAsyncOperationMap } from '../types.ts' +import type { Effect, Operation, ToAsyncOperationMap } from '../types.ts' import type { List } from '../list/types.ts' /** - * A host failure, normalized: whatever the runtime threw reduced to a - * serializable record. `code` is the OS error code when the host supplied one - * (`'ENOENT'`, `'EEXIST'`), absent otherwise. - * - * It is a tagged tuple for the same reason {@link NotImplemented} is — the two - * share an error channel, and the tag is what tells them apart. That - * distinction is the whole reason this type exists: with a bare `unknown` - * error, `NotImplemented | unknown` collapses to `unknown` and a program can no - * longer tell "this runner cannot do it" from "the host tried and failed". - * - * Normalizing also keeps the channel serializable. A thrown `Error` carries a - * stack, a `cause`, and arbitrary own properties; none of it survives a wire - * hop, and a runner in another process could not reproduce it. - */ -export type IoError = readonly['ioError', IoErrorInfo] - -export type IoErrorInfo = { - readonly code?: string - readonly message: string -} - -/** - * The result of an operation with no failures of its own: it either produces - * its value or reports that the runner does not implement it. - * - * Every operation's return type is a `Result`, including the ones that cannot - * fail on their own terms — an operation left on a raw contract would be a hole - * in the error channel, and a runner may omit a handler for any of them. - */ -export type OpResult = Result - -/** - * The error channel of anything that performs host IO: a normalized host - * failure, or the report that the runner does not implement the operation. - * - * It is one name rather than a union spelled at each site, and that is a - * migration property rather than brevity. An effect that does no IO *yet* is - * one added `readFile` away from doing some, and if each signature names its - * own errors, that one change walks up every enclosing signature — the failure - * mode that sank `throws` clauses elsewhere, where engineers eventually - * declared everything throwing rather than maintain the cascade. Declaring the - * standard channel once is that concession made deliberately: an IO-touching - * effect says it fails *the way node IO fails*, and gaining a new way to do so - * changes nothing above it. - * - * It is not a licence to widen. An operation with failures of its own extends - * the channel (`IoChannel | ParseError`), and a computation whose errors are - * genuinely narrower should say so — this is the default for IO, not a ceiling. - */ -export type IoChannel = NotImplemented | IoError - -/** - * The result of an operation that performs host IO: its value, a normalized - * host failure, or the missing-handler report. - */ -export type IoResult = Result - -// all - -/** - * Runs its effects concurrently and answers each one's whole `Result`. - * - * The nesting is deliberate and belongs to the runner: this envelope says - * whether `all` itself could be dispatched, and each inner `Result` is what - * that effect answered. `allOk` (`./module.f.mjs`) is the collapse a fallible - * chain wants. + * The operations no host owns, re-exported so a consumer that already names + * this module for `ReadFile` keeps naming it for `Sandbox` and `All` too. They + * are declared in [`../common/types.ts`](../common/types.ts), which the browser + * runner reads without reaching a Node type. */ -export type All = ['all', (...effects: Effect[]) => OpResult[]>] - -// fetch - -export type Fetch = ['fetch', (url: string) => IoResult] +export type { + All, Await, Fetch, Import, IoChannel, IoError, IoErrorInfo, IoResult, Module, + Now, OpResult, Sandbox, SandboxResult, +} from '../common/types.ts' // mkdir @@ -261,12 +198,6 @@ export type Http = CreateServer | Listen export type Forever = ['forever', () => OpResult] -// import - -export type Module = StringMap - -export type Import = ['import', (path: string) => IoResult] - // write /** Named output streams accepted by the `Write` effect. */ @@ -299,40 +230,6 @@ export type Read = readonly['read', (stream: ReadConsoles) => OpResult -// now - -export type Now = readonly['now', () => OpResult] - -// sandbox - -/** - * The outcome of a `Sandbox` operation. - * - * `result` carries either `['ok', value]` or `['error', thrown]`. `duration` - * is a floating-point millisecond count with up to microsecond precision, - * matching `performance.now()` directly. Additional fields (allocated memory, - * max stack depth, coverage) may be added in future without breaking consumers. - */ -export type SandboxResult = { - readonly result: Result - /** - * Elapsed time in milliseconds (microsecond precision via `performance.now()`). - * The virtual runner returns `0` for deterministic tests. - */ - readonly duration: number -} - -export type Sandbox = readonly['sandbox', (f: () => T) => OpResult>] - -/** - * Resolves the return value of a test function inside the effect runner. - * If `p` is a real `Promise`, it is awaited and rejections propagate as - * throws. If `p` is any other value it is returned as-is. Plain thenables - * (objects with a `.then` method that are not `instanceof Promise`) are - * treated as ordinary values — not awaited. See `fjs/dev/tf/README.md`. - */ -export type Await = readonly['await', (p: unknown) => OpResult] - // Test registration /** @@ -371,18 +268,13 @@ export type Test = export type NodeOp = | Access - | All - | Await - | Fetch + | CommonOp | Fs | Http | Forever - | Import | MemOp - | Now | RandomInt | Read - | Sandbox | Write | Test diff --git a/fjs/emergent_testing/README.md b/fjs/emergent_testing/README.md index 883600e00..703a1c99d 100644 --- a/fjs/emergent_testing/README.md +++ b/fjs/emergent_testing/README.md @@ -83,9 +83,47 @@ Then invoke the runner: - `bun test` - `deno test --allow-read --allow-env --allow-sys` +### The browser + +[`browser/module.mjs`](./browser/module.mjs) runs the same proofs inside a +browser realm and answers a serializable report. The generated website hosts it; +see [`todo/browser-testing.md`](./todo/browser-testing.md) for the automated +runners still to come. + You can also implement your own runner, as long as it follows the proof-tree conventions described below. +## Design: one runner, several hosts + +`fjs t` and the browser runner are **the same runner**. Discovering +zero-argument leaves, walking the tree a proof returns, the structural `throw` +expectation, resolving real promises, formatting paths and counting results all +live once, in [`module.f.mjs`](./module.f.mjs); a host supplies only two things. + +- **A `Reporter`.** It receives semantic events — one normalized `TestResult` + per leaf, and the totals — and decides how they are shown. `defaultReporter` + writes coloured lines (or GitHub annotations); `recordingReporter` hands each + result to the `report` operation, and the browser adapter renders it into the + page. A `TestResult` carries no terminal text and no DOM, so neither reporter + can smuggle presentation back into the core. +- **An effect runner.** `sandbox` is the one operation that actually *executes* + a proof body, and each host implements it against its own realm — Node in + [`../effects/node/module.mjs`](../effects/node/module.mjs), the browser in + [`../effects/browser/module.mjs`](../effects/browser/module.mjs). Both + implement it identically, because a suite that meant different things in the + two would not be one suite. + +The two runners *used* to be two implementations of the same rules, in +`module.f.mjs` and a standalone `browser.mjs`, and the rules had begun to drift. +Consult that history before adding a rule to either host: it belongs in the +core, or it is not a rule about proofs. + +External runners (`node --test`, `bun test`, `deno test`) are the one genuine +exception, and `registerModule` is why: those frameworks own scheduling and +counting, so they are handed the tree rather than driven through it. The +differences that follow from that are documented in +[`todo/661-test-runner-behavior.md`](./todo/661-test-runner-behavior.md). + ## Design: dependency-free proofs Unlike most test frameworks (Jest, Mocha, Vitest, …), a proof does **not** import @@ -232,6 +270,12 @@ to decide whether to await it. Only genuine `Promise` instances are awaited; plain *thenables* — objects with a `.then` method that are not `instanceof Promise` — are treated as ordinary return values and walked as sub-trees. +Every runner asks the same question, in the same place — the `sandbox` +operation — so a suite means the same thing under `fjs t` and in a browser. One +consequence is that a promise built in *another* realm is not `instanceof +Promise` and so is not awaited; see +[`todo/hostile-proof-values.md`](./todo/hostile-proof-values.md). + This is intentional. FunctionalScript does not allow direct `Promise` construction; `Promise` objects only arise as the return value of `async` functions (an Effect). A plain `{ then: f }` object in FunctionalScript is almost diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs deleted file mode 100644 index 3d280f3bf..000000000 --- a/fjs/emergent_testing/browser.mjs +++ /dev/null @@ -1,455 +0,0 @@ -/** - * Browser-native proof execution and report rendering. - * - * The module deliberately has no Node dependencies: generated applications - * import it directly as an ES module in the browser. - * Proof failures resolve the published report with `status: 'failed'`; an - * automated outer controller is responsible for consuming that status and - * choosing a nonzero process exit code. - * - * Every DOM entry point reaches the page through the `root` element it is - * given — `root.ownerDocument` and its `defaultView` — never through the - * runner realm's own `window`/`document`. A page embedding the suite in an - * iframe therefore renders into that frame, and a proof can drive the module - * with a stand-in root. - * - * @module - * - * @import { _TestAndPath } from './types.ts' - */ - -import { collectTests, fmtPath } from './module.f.mjs' - -/** @type {(value: unknown) => string} */ -const text = value => { - try { - return String(value) - } catch { - return 'Unknown thrown value' - } -} - -/** - * The message and stack to report a thrown value by. - * - * An Error thrown from another realm — an iframe, a worker — is not - * `instanceof Error` here, and its stack is the very thing the report exists to - * carry. What the fields say is therefore the test, not where the value was - * made: anything carrying `message` or `stack` is read as the failure it - * describes, and everything else by its own text. - * - * @type {(error: unknown) => readonly [string, string]} - */ -const errorDetails = error => { - try { - if (error !== null && (typeof error === 'object' || typeof error === 'function') - && ('message' in error || 'stack' in error)) { - const { message, stack } = /** @type {{ readonly message?: unknown, readonly stack?: unknown }} */ (error) - const described = text(message) - return [described, stack === undefined ? described : text(stack)] - } - } catch { - // Reading the fields, and asking whether they are there at all, are - // user-observable operations: revoked proxies and accessors can throw - // while the failure is inspected. - } - const fallback = text(error) - return [fallback, fallback] -} - -/** @typedef {{ readonly module: string, readonly path: string, readonly status: string, readonly duration: number, readonly message?: string, readonly stack?: string }} _BrowserTestResult */ -/** @typedef {{ readonly status: string, readonly browser: string, readonly totals: { readonly tests: number, readonly passed: number, readonly failed: number }, readonly duration: number, readonly results: readonly _BrowserTestResult[] }} BrowserTestReport */ - -/** - * Attaches the handlers with the intrinsic `then`, but answers with a promise - * of this realm instead of the one `then` returns. That result is built by - * `constructor[Symbol.species]`, which a promise can make an ordinary object: - * awaiting it would end the test before the promise it came from ever settled - * and put the species object itself in the report. - * - * The `then` call still throws — before either handler is attached — for a - * value that is not a promise or whose species construction fails, which is - * what `runPromise` reads. - * - * @type {(value: unknown, fulfilled: (value: unknown) => Promise | readonly _BrowserTestResult[], rejected: (error: unknown) => readonly _BrowserTestResult[]) => Promise} - */ -const subscribe = (value, fulfilled, rejected) => { - /** @type {(results: Promise | readonly _BrowserTestResult[]) => void} */ - let settle = () => undefined - /** @type {Promise} */ - const settled = new Promise(resolve => { settle = resolve }) - Reflect.apply(Promise.prototype.then, value, [ - /** @type {(value: unknown) => void} */ (resolved => settle(fulfilled(resolved))), - /** @type {(error: unknown) => void} */ (error => settle(rejected(error))), - ]) - return settled -} - -/** - * Reproduces the lookup `then` performs before it builds its result promise: - * `constructor`, then its `Symbol.species`. A genuine promise with a hostile - * species throws here too; an object that only claims to be a promise failed - * the brand check first and reads its `constructor` cleanly. That is what - * separates a promise nothing can subscribe to from an ordinary proof tree, - * once shadowing `constructor` has turned out to be impossible. - * - * @type {(value: unknown) => boolean} - */ -const speciesFails = value => { - try { - if (value === null || value === undefined) { return false } - const { constructor } = /** @type {{ readonly constructor?: unknown }} */ (value) - if (constructor === null || constructor === undefined) { return false } - // The species itself never matters, only whether reading it completes: - // that is the step `then` takes before it builds its result. - void /** @type {{ readonly [Symbol.species]?: unknown }} */ (constructor)[Symbol.species] - return false - } catch { - return true - } -} - -/** - * Runs the intrinsic Promise `then` only for genuine promises. The first call - * is both the native brand check and the normal await path, so arbitrary proof - * objects with a `then` key are never assimilated. - * - * A genuine Promise can still throw after passing the brand check if species - * construction fails. In that case, temporarily shadow `constructor` with the - * current realm's Promise and retry the same intrinsic call; the shadow is - * removed immediately after the handlers are attached. - * - * A promise that pins its own `constructor`, or is frozen, leaves nothing to - * shadow, so no subscription is possible at all. The species failure is then - * reported against the test that produced the promise — the same outcome - * `await` gives it in the Node runner — because a result nobody can observe is - * not a pass. A non-extensible object that merely claims to be a promise - * reaches the same dead end and is still walked as the proof tree it is. - * - * @type {(value: unknown, fulfilled: (value: unknown) => Promise | readonly _BrowserTestResult[], rejected: (error: unknown) => readonly _BrowserTestResult[]) => Promise | null} - */ -const runPromise = (value, fulfilled, rejected) => { - const call = () => subscribe(value, fulfilled, rejected) - try { - return call() - } catch (error) { - // Either `value` is not a promise and the brand check rejected it - // before any handler was attached, or it is a genuine promise that - // failed while constructing the result through Symbol.species. Only - // the second case is worth a retry, and `then` attaches nothing before - // it throws, so the retry cannot run the handlers twice. - try { - if (Object.prototype.toString.call(value) !== '[object Promise]') { return null } - } catch { - return null - } - if (value === null || (typeof value !== 'object' && typeof value !== 'function')) { return null } - /** @type {PropertyDescriptor | undefined} */ - let descriptor - try { - descriptor = Object.getOwnPropertyDescriptor(value, 'constructor') - Object.defineProperty(value, 'constructor', { value: Promise, configurable: true }) - } catch { - // Nothing to shadow, so the value is whatever its own lookup says: - // a promise that cannot be subscribed to fails on the species error - // rather than passing on a result that was never awaited, and a - // frozen spoof is an ordinary proof tree. - return speciesFails(value) ? Promise.resolve(rejected(error)) : null - } - try { - return call() - } catch { - // The intrinsic `constructor` cannot fail the retry, so the brand - // check did: `value` only claims to be a promise and is walked as - // an ordinary proof result. - return null - } finally { - try { - if (descriptor === undefined) { - Reflect.deleteProperty(value, 'constructor') - } else { - Object.defineProperty(value, 'constructor', descriptor) - } - } catch { - // The temporary property is configurable, so ordinary objects - // restore cleanly. A hostile Proxy can make restoration itself - // observable. - } - } - } -} - -/** @type {(module: string, path: readonly (string | null)[], throws: boolean, fn: () => unknown, result: (result: _BrowserTestResult) => void) => Promise} */ -const runOne = (module, path, throws, fn, result) => { - const start = performance.now() - /** @type {(value: unknown) => Promise | readonly _BrowserTestResult[]} */ - const passed = value => { - const duration = performance.now() - start - if (throws) { - const failure = { module, path: fmtPath(path), status: 'failed', duration, - message: 'Expected the proof to throw', stack: '' } - result(failure) - return [failure] - } - // Reading the returned tree runs user code: an enumerable getter - // or a proxy trap can throw. That is a failure of the test that - // produced the value, never of the run — a rejected run leaves the - // page in `running` with no report and no completion event. - /** @type {readonly _TestAndPath[]} */ - let children - try { - children = collectTests([...path, null], false, value) - } catch (error) { - return failed(error) - } - return Promise.all(children.map(([childPath, child]) => - runOne(module, childPath, child.throws, child.fn, result) - )).then(results => { - const success = { module, path: fmtPath(path), status: 'passed', duration } - result(success) - return [success, ...results.flat()] - }) - } - /** @type {(error: unknown) => readonly _BrowserTestResult[]} */ - const failed = error => { - const duration = performance.now() - start - if (throws) { - const success = { module, path: fmtPath(path), status: 'passed', duration } - result(success) - return [success] - } - const [message, stack] = errorDetails(error) - const failure = { module, path: fmtPath(path), status: 'failed', duration, message, stack } - result(failure) - return [failure] - } - // Wrap the raw return so Promise resolution does not assimilate arbitrary - // objects with a `then` proof property. The Node runner awaits only actual - // promises, and browser execution must preserve that same test-tree rule. - return Promise.resolve().then(() => [fn()]).then( - ([value]) => runPromise(value, passed, failed) ?? passed(value), - failed - ) -} - -/** @type {(status: string, duration: number, results: readonly _BrowserTestResult[]) => BrowserTestReport} */ -const reportOf = (status, duration, results) => { - const failed = results.filter(result => result.status === 'failed').length - return { - status, - browser: navigator.userAgent, - totals: { tests: results.length, passed: results.length - failed, failed }, - duration, - results, - } -} - -/** - * Runs named proof exports and returns the serializable browser report. - * - * @type {(modules: readonly (readonly [string, unknown])[], result?: (result: _BrowserTestResult) => void) => Promise} - */ -export const runBrowserProofs = (modules, result = () => undefined) => { - const start = performance.now() - // Reporting each result as it lands is the page's own code. A renderer that - // throws must not take the run down with it: the report it fails to show is - // the one thing the page is still waiting for. - /** @type {(result: _BrowserTestResult) => void} */ - const announce = value => { - try { - result(value) - } catch { - // The result stays in the report the run resolves with. - } - } - /** @type {(module: string, error: unknown) => () => Promise} */ - const unreadable = (module, error) => () => { - const [message, stack] = errorDetails(error) - const failure = { module, path: '', status: 'failed', duration: 0, message, stack } - announce(failure) - return Promise.resolve([failure]) - } - const tests = modules.flatMap(([module, proof]) => { - // Reading an exported tree runs user code just as reading a returned - // one does. A module that cannot be enumerated is one failed module, - // never a run that ends without a report. - try { - return collectTests([], false, proof).map(([path, entry]) => - () => runOne(module, path, entry.throws, entry.fn, announce) - ) - } catch (error) { - return [unreadable(module, error)] - } - }) - const batchSize = 25 - /** @type {(index: number, results: readonly _BrowserTestResult[]) => Promise} */ - const runBatch = (index, results) => { - const batch = tests.slice(index, index + batchSize) - if (batch.length === 0) { return Promise.resolve(results) } - return Promise.all(batch.map(test => test())).then(next => - new Promise(resolve => setTimeout(resolve, 0, [...results, ...next.flat()])) - ).then(next => runBatch(index + batchSize, next)) - } - const completed = runBatch(0, []) - return completed.then(results => reportOf( - results.some(result => result.status === 'failed') ? 'failed' : 'passed', - performance.now() - start, - results, - )) -} - -/** @typedef {(source: string) => Promise<{ readonly proof?: unknown }>} _BrowserImporter */ -/** @typedef {{ readonly status: 'loaded', readonly source: string, readonly proof: unknown } | { readonly status: 'error', readonly source: string, readonly error: unknown }} _LoadedModule */ -/** @typedef {Window & { fjsBrowserTestReport?: Promise }} _TestWindow */ - -/** @type {(root: Element) => _TestWindow | null} */ -const viewOf = root => root.ownerDocument.defaultView - -/** - * Renders the settled report into the page, publishes the run as - * `fjsBrowserTestReport` on the root's window, and announces it with - * `fjs-browser-test-complete`. - * - * @type {(root: Element, report: Promise) => Promise} - */ -const publish = (root, report) => { - const view = viewOf(root) - const done = report.then(value => { - renderBrowserReport(root, value) - view?.dispatchEvent(new CustomEvent('fjs-browser-test-complete', { detail: value })) - return value - }) - if (view !== null) { view.fjsBrowserTestReport = done } - return done -} - -/** - * Loads proof modules after the page has rendered, reporting module-loading - * progress before proof execution begins. - * - * @type {(root: Element, sources: readonly string[], importer: _BrowserImporter) => Promise} - */ -export const startBrowserTestSources = (root, sources, importer) => { - const start = performance.now() - setState(root, 'loading') - let loaded = 0 - const summary = root.querySelector('[data-test-summary]') - // Set synchronously, before any import settles: otherwise the page keeps - // showing its idle text throughout loading — indefinitely, if a module - // import never settles — even though the state and control already - // changed. - if (summary !== null) { summary.textContent = `Loading 0/${sources.length}` } - // The importer is supplied by the page, so obtaining the promise is itself - // a failure point: a synchronous throw becomes a rejection here and is - // reported as a loader failure, rather than escaping past a `loading` state - // that no report or completion event ever replaces. - /** @type {(source: string) => Promise<{ readonly proof?: unknown }>} */ - const load = source => { - try { - return importer(source) - } catch (error) { - return Promise.reject(error) - } - } - /** @type {Promise} */ - const modules = Promise.all(sources.map(source => load(source).then( - module => { - loaded += 1 - if (summary !== null) { summary.textContent = `Loading ${loaded}/${sources.length}: ${source}` } - return /** @type {const} */ ({ status: 'loaded', source, proof: module.proof }) - }, - error => /** @type {const} */ ({ status: 'error', source, error }) - ))) - const report = modules.then(loadedModules => { - const rejected = loadedModules.flatMap(module => - module.status === 'error' ? [module] : []) - if (rejected.length !== 0) { - // A module that never linked has no tests to run, so the run stops - // here. Each rejection is still counted as a failed result: totals - // that disagreed with `results` would tell an automated consumer - // the suite was empty rather than broken. - const duration = performance.now() - start - return publish(root, Promise.resolve(reportOf('infrastructure-error', duration, - rejected.map(({ source, error }) => { - const [message, stack] = errorDetails(error) - return { module: source, path: '', status: 'failed', duration, message, stack } - })))) - } - return startBrowserTests(root, loadedModules.flatMap(module => - module.status === 'loaded' - ? [/** @type {const} */ ([module.source, module.proof])] - : [])) - }) - const view = viewOf(root) - if (view !== null) { view.fjsBrowserTestReport = report } - return report -} - -/** - * Sets the runner state and keeps the `Run` control's real disabled state in - * sync with it: passive while a suite is loading or running, active in every - * other state (idle, or any terminal status). A disabled attribute is used - * rather than a click handler that silently ignores the action, so assistive - * technology sees the same unavailability a sighted user does. - * - * @type {(root: Element, state: string) => void} - */ -const setState = (root, state) => { - root.setAttribute('data-state', state) - const runButton = root.querySelector('[data-test-run]') - if (runButton !== null) { - if (state === 'loading' || state === 'running') { - runButton.setAttribute('disabled', '') - } else { - runButton.removeAttribute('disabled') - } - } -} - -/** - * Renders a completed report in the browser test page. - * - * @type {(root: Element, report: BrowserTestReport) => void} - */ -export const renderBrowserReport = (root, report) => { - setState(root, report.status) - const summary = root.querySelector('[data-test-summary]') - if (summary !== null) { - summary.textContent = report.status === 'infrastructure-error' - ? `Infrastructure error: ${report.totals.failed} failed to load (${report.duration.toFixed(1)} ms)` - : `${report.totals.passed} passed, ${report.totals.failed} failed (${report.duration.toFixed(1)} ms)` - } - const output = root.querySelector('[data-test-results]') - if (output !== null) { - output.replaceChildren(...report.results.map(result => - renderResult(root.ownerDocument, result))) - } -} - -/** @type {(document: Document, result: _BrowserTestResult) => HTMLLIElement} */ -const renderResult = (document, result) => { - const item = document.createElement('li') - item.setAttribute('data-status', result.status) - const detail = result.status === 'failed' ? `: ${result.message}\n${result.stack}` : '' - item.textContent = `${result.status === 'passed' ? 'PASS' : 'FAIL'} ${result.module} ${result.path} (${result.duration.toFixed(1)} ms)${detail}` - return item -} - -/** - * Runs the application, publishes its promise as `window.fjsBrowserTestReport`, - * and dispatches `fjs-browser-test-complete` with the report in `detail`. - * - * @type {(root: Element, modules: readonly (readonly [string, unknown])[]) => Promise} - */ -export const startBrowserTests = (root, modules) => { - setState(root, 'running') - const output = root.querySelector('[data-test-results]') - if (output !== null) { output.replaceChildren() } - let completed = 0 - return publish(root, runBrowserProofs(modules, result => { - completed += 1 - const summary = root.querySelector('[data-test-summary]') - if (summary !== null) { summary.textContent = `${completed} tests completed…` } - if (output !== null) { output.append(renderResult(root.ownerDocument, result)) } - })) -} diff --git a/fjs/emergent_testing/browser/module.f.mjs b/fjs/emergent_testing/browser/module.f.mjs new file mode 100644 index 000000000..45718d224 --- /dev/null +++ b/fjs/emergent_testing/browser/module.f.mjs @@ -0,0 +1,140 @@ +/** + * The browser proof application: link the proof modules, run them through the + * shared emergent-testing core, and answer one serializable report. + * + * **It performs no browser operation of its own.** Linking a module, reading + * the clock, executing a proof body and recording a result are all operations + * (`./types.ts`), so this program is exactly as runnable from a proof with a + * stand-in interpreter as it is from a page. What is genuinely the browser's — + * the DOM, the published promise, the completion event — lives in the impure + * adapter beside it, [`./module.mjs`](./module.mjs). + * + * **It owns no proof semantics either.** Discovering zero-argument leaves, + * walking a returned tree, the structural `throw` expectation, resolving real + * promises and counting results are `../module.f.mjs`'s, the same module `fjs t` + * runs through — this file only decides what a *run* is: load, run, report. + * + * @module + * + * @import { Effect } from '../../effects/types.ts' + * @import { Module } from '../../effects/common/types.ts' + * @import { IoChannel, Import } from '../../effects/common/types.ts' + * @import { TestResult } from '../types.ts' + * @import { BrowserOp, BrowserProgram, BrowserTestReport, ReportStatus, _Loaded } from './types.ts' + */ + +import { allOk, errorMessage, import_, now } from '../../effects/common/module.f.mjs' +import { history, historyStep, mapStep, pureOk, resultMapStep, step } from '../../effects/module.f.mjs' +import { recordingReporter, reported, runModuleMap } from '../module.f.mjs' +import { fromEntries } from '../../types/object/module.f.mjs' +import { ok } from '../../types/result/module.f.mjs' + +/** + * Builds the report from the results a run recorded. Totals are counted here + * rather than reported separately, so they cannot disagree with `results`. + * + * @type {(status: ReportStatus, browser: string, duration: number, results: readonly TestResult[]) => BrowserTestReport} + */ +export const reportOf = (status, browser, duration, results) => { + const failed = results.filter(result => result.status === 'failed').length + return { + status, + browser, + totals: { tests: results.length, passed: results.length - failed, failed }, + duration, + results, + } +} + +/** + * The result standing for something that went wrong outside any proof: a module + * that would not link, or an operation the runner does not implement. + * + * It is counted as a failed result rather than left out. Totals that disagreed + * with `results` would tell an automated consumer the suite was empty rather + * than broken. + * + * @type {(module: string, message: string) => TestResult} + */ +const infrastructureResult = (module, message) => + ({ module, path: '', status: 'failed', duration: 0, message, stack: '' }) + +/** + * Links one source, keeping the failure rather than propagating it: a run + * reports *every* module that would not link, and the first one would + * short-circuit the rest away. + * + * @type {(source: string) => Effect} + */ +const loadOne = source => resultMapStep(import_(source), r => { + /** @type {_Loaded} */ + const loaded = [source, r] + return ok(loaded) +}) + +/** @type {(results: readonly TestResult[]) => ReportStatus} */ +const statusOf = results => + results.some(result => result.status === 'failed') ? 'failed' : 'passed' + +/** @internal What a run answers before it is timed and packaged. */ +/** @typedef {readonly[ReportStatus, readonly TestResult[]]} _Outcome */ + +/** + * Runs the modules that linked, or reports the ones that did not. + * + * A module that never linked has no tests to run, so the run stops at the first + * broken graph rather than reporting a partial suite as a complete one. + * + * @type {(loaded: readonly _Loaded[]) => Effect} + */ +const runLoaded = loaded => { + const linked = loaded.flatMap(([source, r]) => + r[0] === 'ok' ? [/** @type {const} */ ([source, r[1]])] : []) + if (linked.length !== loaded.length) { + /** @type {_Outcome} */ + const broken = ['infrastructure-error', loaded.flatMap(([source, r]) => + r[0] === 'error' ? [infrastructureResult(source, errorMessage(r[1]))] : [])] + return pureOk(broken) + } + const ran = runModuleMap(recordingReporter)(fromEntries(linked)) + const collected = step(ran, () => reported()) + return mapStep(collected, results => { + /** @type {_Outcome} */ + const outcome = [statusOf(results), results] + return outcome + }) +} + +/** @type {(sources: readonly string[]) => Effect} */ +const runSources = sources => + step(allOk(...sources.map(loadOne)), runLoaded) + +/** + * A run that could not finish, reported as one infrastructure error against the + * run itself. + * + * This is what makes {@link BrowserProgram}'s empty error channel true: a + * runner that cannot dispatch `sandbox`, `now` or `report` leaves the program + * with nothing to answer, and a page waiting on the run has nowhere to put a + * failure it never receives. + * + * @type {(browser: string, message: string) => BrowserTestReport} + */ +const failedRun = (browser, message) => + reportOf('infrastructure-error', browser, 0, [infrastructureResult('', message)]) + +/** + * The application: link every source, run the proofs that linked, and answer + * the report. + * + * @type {BrowserProgram} + */ +export const main = ({ browser, sources }) => { + const started = history(now()) + const outcome = historyStep(started, () => runSources(sources)) + const ended = historyStep(outcome, () => now()) + const report = mapStep(ended, ([end, [status, results], start]) => + reportOf(status, browser, end - start, results)) + return resultMapStep(report, r => + ok(r[0] === 'error' ? failedRun(browser, errorMessage(r[1])) : r[1])) +} diff --git a/fjs/emergent_testing/browser/module.mjs b/fjs/emergent_testing/browser/module.mjs new file mode 100644 index 000000000..93b753afb --- /dev/null +++ b/fjs/emergent_testing/browser/module.mjs @@ -0,0 +1,177 @@ +/** + * The browser host adapter: capabilities, DOM rendering, and publication. + * + * It owns nothing about what a proof *means*. Walking proof trees, the + * structural `throw` expectation, resolving real promises, path formatting and + * the totals belong to `../module.f.mjs` — the module `fjs t` runs through — + * and what a *run* is belongs to the pure application in + * [`./module.f.mjs`](./module.f.mjs). What is left here is the browser: an + * interpreter for the operations that application performs, the DOM it is + * rendered into, and the promise and event a controller reads it from. + * + * The module deliberately has no Node dependency: generated applications import + * it directly as an ES module in the browser. + * Proof failures resolve the published report with `status: 'failed'`; an + * automated outer controller is responsible for consuming that status and + * choosing a nonzero process exit code. + * + * Every DOM entry point reaches the page through the `root` element it is + * given — `root.ownerDocument` and its `defaultView` — never through the + * runner realm's own `window`/`document`. A page embedding the suite in an + * iframe therefore renders into that frame, and a proof can drive the module + * with a stand-in root. + * + * @module + * + * @import { Effect } from '../../effects/types.ts' + * @import { Result } from '../../types/result/types.ts' + * @import { BrowserImporter } from '../../effects/browser/module.mjs' + * @import { TestResult } from '../types.ts' + * @import { BrowserOp, BrowserTestReport } from './types.ts' + */ + +import { asyncRun } from '../../effects/module.mjs' +import { browserOperationMap } from '../../effects/browser/module.mjs' +import { main } from './module.f.mjs' +import { ok } from '../../types/result/module.f.mjs' + +/** @typedef {Window & { fjsBrowserTestReport?: Promise }} _TestWindow */ + +/** @typedef {(effect: Effect) => Promise>} _Run */ + +/** + * How many results may be rendered before the runner hands the event loop back. + * + * Every operation resolves through a microtask, and microtasks do not let a + * browser paint: without a real task boundary the page would show its first + * frame again only once the whole suite had finished. Yielding per result would + * be the simpler rule and the wrong one — `setTimeout` clamps to 4 ms once + * nested, which is minutes across a few thousand proofs. + */ +const batchSize = 25 + +/** @type {() => Promise} */ +const macrotask = () => new Promise(resolve => { setTimeout(resolve, 0) }) + +/** @type {(root: Element) => _TestWindow | null} */ +const viewOf = root => root.ownerDocument.defaultView + +/** + * Sets the runner state and keeps the `Run` control's real disabled state in + * sync with it: passive while a suite is loading or running, active in every + * other state (idle, or any terminal status). A disabled attribute is used + * rather than a click handler that silently ignores the action, so assistive + * technology sees the same unavailability a sighted user does. + * + * @type {(root: Element, state: string) => void} + */ +const setState = (root, state) => { + root.setAttribute('data-state', state) + const runButton = root.querySelector('[data-test-run]') + if (runButton !== null) { + if (state === 'loading' || state === 'running') { + runButton.setAttribute('disabled', '') + } else { + runButton.removeAttribute('disabled') + } + } +} + +/** @type {(document: Document, result: TestResult) => HTMLLIElement} */ +const renderResult = (document, result) => { + const item = document.createElement('li') + item.setAttribute('data-status', result.status) + const detail = result.status === 'failed' ? `: ${result.message}\n${result.stack}` : '' + item.textContent = `${result.status === 'passed' ? 'PASS' : 'FAIL'} ${result.module} ${result.path} (${result.duration.toFixed(1)} ms)${detail}` + return item +} + +/** + * Renders a completed report in the browser test page. + * + * @type {(root: Element, report: BrowserTestReport) => void} + */ +export const renderBrowserReport = (root, report) => { + setState(root, report.status) + const summary = root.querySelector('[data-test-summary]') + if (summary !== null) { + summary.textContent = report.status === 'infrastructure-error' + ? `Infrastructure error: ${report.totals.failed} failed to load (${report.duration.toFixed(1)} ms)` + : `${report.totals.passed} passed, ${report.totals.failed} failed (${report.duration.toFixed(1)} ms)` + } + const output = root.querySelector('[data-test-results]') + if (output !== null) { + output.replaceChildren(...report.results.map(result => + renderResult(root.ownerDocument, result))) + } +} + +/** + * Runs the browser application against `root`, publishes its promise as + * `fjsBrowserTestReport` on the root's window, and dispatches + * `fjs-browser-test-complete` with the report in `detail`. + * + * `importer` is the seam a controller reaches for: an application root resolves + * its own specifiers, and a proof drives the whole runner without a network. + * The default is the realm's own dynamic `import`. + * + * @type {(root: Element, sources: readonly string[], importer?: BrowserImporter) => Promise} + */ +export const startBrowserTestSources = (root, sources, importer = source => import(source)) => { + setState(root, 'loading') + const summary = root.querySelector('[data-test-summary]') + const output = root.querySelector('[data-test-results]') + if (output !== null) { output.replaceChildren() } + // Set synchronously, before any import settles: otherwise the page keeps + // showing its idle text throughout loading — indefinitely, if a module + // import never settles — even though the state and control already changed. + if (summary !== null) { summary.textContent = `Loading 0/${sources.length}` } + let loaded = 0 + /** @type {(source: string) => void} */ + const linked = source => { + loaded += 1 + if (summary !== null) { summary.textContent = `Loading ${loaded}/${sources.length}: ${source}` } + // Whether the module linked or not, the loading phase is over once the + // last answer is in — a broken graph is reported by the run, not by + // leaving the page in `loading` forever. + if (loaded === sources.length) { setState(root, 'running') } + } + /** @type {BrowserImporter} */ + const load = source => importer(source).then( + module => { linked(source); return module }, + error => { linked(source); throw error }) + /** @type {readonly TestResult[]} */ + let results = [] + /** @type {_Run} */ + const run = asyncRun({ + ...browserOperationMap(effect => run(effect), load), + report: async result => { + results = [...results, result] + if (summary !== null) { summary.textContent = `${results.length} tests completed…` } + if (output !== null) { output.append(renderResult(root.ownerDocument, result)) } + if (results.length % batchSize === 0) { await macrotask() } + return ok(undefined) + }, + reported: async () => ok(results), + }) + const view = viewOf(root) + // The application's error channel is empty — every failure it can meet is + // reported — so the run's `Result` is always `ok` and `unwrap` would only + // add a panic path nothing can reach. + const report = run(main({ browser: navigatorName(root), sources })).then(([, value]) => { + renderBrowserReport(root, value) + view?.dispatchEvent(new CustomEvent('fjs-browser-test-complete', { detail: value })) + return value + }) + if (view !== null) { view.fjsBrowserTestReport = report } + return report +} + +/** + * The realm the run is recorded under, read through the root's own window so an + * embedded suite names the frame it actually runs in — and so a proof driving + * the runner with a stand-in root never needs a global `navigator`. + * + * @type {(root: Element) => string} + */ +const navigatorName = root => viewOf(root)?.navigator.userAgent ?? '' diff --git a/fjs/emergent_testing/browser/proof.f.mjs b/fjs/emergent_testing/browser/proof.f.mjs new file mode 100644 index 000000000..e46c4cf31 --- /dev/null +++ b/fjs/emergent_testing/browser/proof.f.mjs @@ -0,0 +1,162 @@ +/** + * Proofs for the browser proof application. + * + * The application performs only operations, so a state-threading stand-in + * interpreter is enough to drive every path from Node — no browser, no DOM, and + * no globals for these proofs to install and unset. `sandbox` is the same + * pass-through the virtual Node runner uses: a fixture returns the + * `SandboxResult` it wants reported, so outcomes are dictated rather than + * measured. + * + * @import { Result } from '../../types/result/types.ts' + * @import { MemOperationMap, RunInstance } from '../../effects/mock/types.ts' + * @import { Module, SandboxResult } from '../../effects/common/types.ts' + * @import { StringMap } from '../../types/object/types.ts' + * @import { TestResult } from '../types.ts' + * @import { BrowserOp, BrowserTestReport } from './types.ts' + */ + +import { assert, assertEq } from '../../asserts/module.f.mjs' +import { ioError } from '../../effects/common/module.f.mjs' +import { notImplemented } from '../../effects/module.f.mjs' +import { run as mockRun } from '../../effects/mock/module.f.mjs' +import { error, ok, unwrap } from '../../types/result/module.f.mjs' +import { main } from './module.f.mjs' + +/** + * @typedef {{ + * readonly time: number, + * readonly clock: boolean, + * readonly results: readonly TestResult[], + * readonly modules: StringMap, + * }} _State + */ + +/** @type {MemOperationMap} */ +const map = { + all: (...a) => state => { + /** @type {readonly Result[]} */ + let e = [] + for (const i of a) { + const [ns, ei] = browser(state)(i) + state = ns + e = [...e, ei] + } + return [state, ok(e)] + }, + await: p => state => [state, ok([p])], + fetch: () => state => [state, error(ioError({ message: 'no network' }))], + import: source => state => { + const module = state.modules[source] + return [ + state, + module === undefined + ? error(ioError({ code: 'ENOENT', message: `cannot link ${source}` })) + : ok(module), + ] + }, + // A clock that ticks once per read, so a run's duration is the number of + // reads between its ends and never a real elapsed time. + now: () => state => [ + { ...state, time: state.time + 1 }, + state.clock ? ok(state.time) : error(notImplemented('now')), + ], + sandbox: f => state => [state, ok(/** @type {SandboxResult} */ (f()))], + report: result => state => [{ ...state, results: [...state.results, result] }, ok(undefined)], + reported: () => state => [state, ok(state.results)], +} + +/** @type {RunInstance} */ +const browser = mockRun(map) + +/** @type {(sources: readonly string[], modules: StringMap, clock?: boolean) => BrowserTestReport} */ +const run = (sources, modules, clock = true) => { + /** @type {_State} */ + const state = { time: 100, clock, results: [], modules } + const [, report] = browser(state)(main({ browser: 'proof', sources })) + return unwrap(report) +} + +/** A leaf that passes, taking 2 ms. + * + * @type {() => unknown} + */ +const pass = () => ({ result: ok(undefined), duration: 2 }) + +/** A leaf that fails with an `Error`. + * + * @type {() => unknown} + */ +const fail = () => ({ result: error(new Error('oops')), duration: 3 }) + +export const proof = { + passing: () => { + const report = run(['a'], { a: { proof: { x: pass } } }) + assertEq(report.status, 'passed') + assertEq(report.browser, 'proof') + assertEq(report.totals.tests, 1) + assertEq(report.totals.passed, 1) + assertEq(report.totals.failed, 0) + // Two clock reads bracket the run, and the stand-in ticks once per read. + assertEq(report.duration, 1) + assertEq(report.results[0]?.module, 'a') + assertEq(report.results[0]?.path, '.x') + assertEq(report.results[0]?.duration, 2) + }, + failing: () => { + const report = run(['a'], { a: { proof: { x: pass, y: fail } } }) + assertEq(report.status, 'failed') + assertEq(report.totals.tests, 2) + assertEq(report.totals.failed, 1) + const failed = report.results.filter(r => r.status === 'failed') + assertEq(failed[0]?.path, '.y') + assertEq(failed[0]?.message, 'oops') + }, + // The proof tree a leaf returns is walked by the same shared core `fjs t` + // uses, so a sub-test is a result of its own with a call boundary in its + // path. + subTree: () => { + const report = run(['a'], { + a: { proof: { outer: () => ({ result: ok({ inner: pass }), duration: 0 }) } }, + }) + assertEq(report.totals.tests, 2) + assertEq(report.results[1]?.path, '.outer().inner') + }, + expectedThrow: () => { + const report = run(['a'], { a: { proof: { throw: { boom: fail, quiet: pass } } } }) + assertEq(report.totals.tests, 2) + assertEq(report.totals.failed, 1) + const failed = report.results.filter(r => r.status === 'failed') + assertEq(failed[0]?.path, '.throw.quiet') + assertEq(failed[0]?.message, 'Expected the proof to throw') + }, + // A module without a `proof` export contributes no tests, and an empty run + // still answers a report rather than nothing. + withoutProof: () => { + const report = run(['a'], { a: {} }) + assertEq(report.status, 'passed') + assertEq(report.totals.tests, 0) + }, + // One module that would not link stops the run: the suite never ran, so its + // status is not the one a failing suite gets, and every rejected source is + // still counted as a failed result. + unlinkable: () => { + const report = run(['a', 'missing'], { a: { proof: { x: pass } } }) + assertEq(report.status, 'infrastructure-error') + assertEq(report.totals.tests, 1) + assertEq(report.totals.failed, 1) + assertEq(report.results[0]?.module, 'missing') + assertEq(report.results[0]?.path, '') + assertEq(report.results[0]?.message, 'cannot link missing') + }, + // A runner missing an operation the application needs is reported the same + // way, which is what makes the program's empty error channel true: a page + // waiting on the run always receives a report. + incompleteRunner: () => { + const report = run(['a'], { a: { proof: { x: pass } } }, false) + assertEq(report.status, 'infrastructure-error') + assertEq(report.duration, 0) + assertEq(report.results[0]?.message, 'operation not implemented: now') + assert(report.results.length === 1, report.results) + }, +} diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index c4af06c77..fc7a7fcb7 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -1,20 +1,29 @@ /** - * Proofs for the browser runner. + * Proofs for the browser host adapter and the browser interpretation of the + * host-independent operations. * - * The runner reaches the page only through the root element it is handed, so + * The adapter reaches the page only through the root element it is handed, so * the DOM stand-in below is enough to drive every rendering branch from Node — * no headless browser, and no global `window`/`document` for these proofs to - * install and unset. + * install and unset. What each proof *means* is settled a layer down, by the + * shared core and its own proofs; what is checked here is that a browser run + * reaches it, renders it, and publishes it. + * + * @import { Module } from '../../effects/common/types.ts' + * @import { CommonRun } from '../../effects/browser/module.mjs' + * @import { BrowserTestReport } from './types.ts' */ -import { runInNewContext } from 'node:vm' - -import { assert, assertEq, assertNotNullish, assertStructurallySame } from '../../asserts/module.f.mjs' -import { renderBrowserReport, runBrowserProofs, startBrowserTests, startBrowserTestSources } from '../browser.mjs' +import { assert, assertEq, assertNotNullish } from '../../asserts/module.f.mjs' +import { browserOperationMap } from '../../effects/browser/module.mjs' +import { asyncRun } from '../../effects/module.mjs' +import { pureOk } from '../../effects/module.f.mjs' +import { renderBrowserReport, startBrowserTestSources } from './module.mjs' +import { unwrap } from '../../types/result/module.f.mjs' /** @typedef {{ readonly tag: string, attributes: ReadonlyMap, readonly ownerDocument: _Document, textContent: string, children: readonly _Element[], readonly setAttribute: (name: string, value: string) => void, readonly removeAttribute: (name: string) => void, readonly querySelector: (selector: string) => _Element | null, readonly replaceChildren: (...nodes: readonly _Element[]) => void, readonly append: (node: _Element) => void }} _Element */ /** @typedef {{ defaultView: _View | null, readonly createElement: (tag: string) => _Element }} _Document */ -/** @typedef {{ events: readonly CustomEvent[], readonly dispatchEvent: (event: Event) => boolean, fjsBrowserTestReport?: Promise }} _View */ +/** @typedef {{ events: readonly CustomEvent[], readonly navigator: { readonly userAgent: string }, readonly dispatchEvent: (event: Event) => boolean, fjsBrowserTestReport?: Promise }} _View */ /** @type {(node: _Element, name: string) => _Element | null} */ const find = (node, name) => @@ -40,7 +49,7 @@ const element = (document, tag, attributes, states) => { removeAttribute: name => { self.attributes = new Map([...self.attributes].filter(([key]) => key !== name)) }, - // The runner only ever queries an attribute selector of `[name]` form. + // The adapter only ever queries an attribute selector of `[name]` form. querySelector: selector => self.children.reduce( (/** @type {_Element | null} */ acc, child) => acc ?? find(child, selector.slice(1, -1)), @@ -52,7 +61,7 @@ const element = (document, tag, attributes, states) => { } /** - * Builds what the generated page gives the runner: a root carrying the summary + * Builds what the generated page gives the adapter: a root carrying the summary * paragraph and the result list. `states` records every `data-state` written, * so a proof can check the whole progression and not just its last step. * @@ -69,6 +78,7 @@ const page = (withView = true) => { /** @type {_View} */ const view = { events: [], + navigator: { userAgent: 'stand-in browser' }, dispatchEvent: event => { view.events = [...view.events, /** @type {CustomEvent} */ (event)] return true @@ -90,339 +100,179 @@ const page = (withView = true) => { } } -/** @type {(proof: unknown) => ReturnType} */ -const run = proof => runBrowserProofs([['proof', proof]]) +/** Runs one in-memory proof module through the whole browser stack. + * + * @type {(proof: unknown) => Promise} + */ +const run = proof => + startBrowserTestSources(page().root, ['proof'], async () => ({ proof })) /** @type {(element: _Element) => readonly (string | undefined)[]} */ const statuses = element => element.children.map(child => child.attributes.get('data-status')) +/** + * The browser handlers on their own, so the operations the proof application + * never reaches — `fetch`, `await`, a nested `all` — are still exercised. + */ +const operations = browserOperationMap( + effect => commonRun(effect), + async source => ({ source })) + +/** @type {CommonRun} */ +const commonRun = asyncRun(operations) + +const { all, await: awaitOp, fetch: fetchOp, import: importOp, now, sandbox } = operations + export const proof = { - namedThrow: async () => { - const named = { throw: () => { throw 'expected' } }.throw - const report = await run({ extracted: named }) + // The whole stack: a module is linked, its proofs run, each result is + // rendered as it lands, and the report is published and announced. + passing: async () => { + const { root, summary, results, view, states } = page() + const report = await startBrowserTestSources(root, ['a'], async () => ({ + proof: { x: () => undefined }, + })) assertEq(report.status, 'passed') + assertEq(report.browser, 'stand-in browser') + assertEq(report.totals.tests, 1) + assertEq(report.results[0]?.path, '.x') + assertEq(statuses(results).join(','), 'passed') + assert(summary.textContent.startsWith('1 passed, 0 failed'), summary.textContent) + assertEq(states.join(','), 'loading,running,passed') + assertEq(view.events.length, 1) + assertEq(/** @type {BrowserTestReport} */ (view.events[0]?.detail).status, 'passed') + assertEq(await view.fjsBrowserTestReport, report) }, - path: async () => { - const report = await run({ 'a.b': () => undefined }) - assertEq(report.results[0]?.path, '["a.b"]') - }, - arbitraryThrow: async () => { - const report = await run({ fail: () => { throw Object.create(null) } }) - assertEq(report.status, 'failed') - assertEq(report.results[0]?.message, 'Unknown thrown value') - }, - errorFields: async () => { - const error = new Proxy(new Error(), { - get: (target, property) => property === 'message' || property === 'stack' - ? Symbol(property) - : Reflect.get(target, property), - }) - const report = await run({ fail: () => { throw error } }) - assertEq(report.results[0]?.message, 'Symbol(message)') - assertEq(report.results[0]?.stack, 'Symbol(stack)') - }, - errorAccessorThrows: async () => { - const error = new Error('hidden') - Object.defineProperty(error, 'message', { - get: () => { throw new Error('message getter failed') }, - }) - const report = await run({ fail: () => { throw error } }) - assertEq(report.status, 'failed') - assertEq(report.results[0]?.message, 'Unknown thrown value') - assertEq(report.results[0]?.stack, 'Unknown thrown value') - }, - revokedErrorProxy: async () => { - const { proxy, revoke } = Proxy.revocable(new Error('revoked'), {}) - revoke() - const report = await run({ fail: () => { throw proxy } }) + failing: async () => { + const { root, results, runButton } = page() + const report = await startBrowserTestSources(root, ['a'], async () => ({ + proof: { boom: () => { throw new Error('bang') } }, + })) assertEq(report.status, 'failed') - assertEq(report.results[0]?.message, 'Unknown thrown value') - }, - crossRealmError: async () => { - // An Error from another realm is not `instanceof Error` here, and its - // stack is what the report exists to carry. - const other = runInNewContext( - '({ fail: () => { throw new Error(\'cross boom\') } })') - const report = await run({ fail: other.fail }) - assertEq(report.results[0]?.message, 'cross boom') - const stack = report.results[0]?.stack ?? '' - assert(stack !== 'cross boom', stack) - assert(stack.includes('cross boom'), stack) - }, - errorWithoutStack: async () => { - const error = new Error('no stack') - const report = await run({ fail: () => { throw Object.assign(error, { stack: undefined }) } }) - assertEq(report.results[0]?.message, 'no stack') - assertEq(report.results[0]?.stack, 'no stack') + assertEq(report.results[0]?.message, 'bang') + assert((report.results[0]?.stack ?? '').includes('bang')) + assertEq(statuses(results).join(','), 'failed') + // The control is available again the moment the run reaches a terminal + // state, and was not while it was loading or running. + assert(!runButton.attributes.has('disabled')) }, expectedThrow: async () => { - const report = await run({ throw: { silent: () => undefined } }) - assertEq(report.status, 'failed') - assertEq(report.results[0]?.message, 'Expected the proof to throw') - }, - crossRealmPromise: async () => { - // A promise built in another realm is not `instanceof Promise`. The - // runner has to await it anyway and walk the tree it resolves to, - // otherwise a rejected cross-realm promise is reported as a pass. - const other = runInNewContext('({ resolve: value => Promise.resolve(value) })') - const report = await run({ - nested: () => other.resolve({ child: () => { throw 'boom' } }), - }) - assertEq(report.totals.tests, 2) - assertEq(report.totals.failed, 1) - assertEq(report.results[1]?.path, '.nested().child') - }, - spoofedPromiseTag: async () => { - const report = await run({ - nested: () => ({ - [Symbol.toStringTag]: 'Promise', - then: /** @type {(...args: (() => void)[]) => void} */ ((...args) => { args[0]?.() }), - }), - }) - assertEq(report.totals.tests, 2) - assertEq(report.results[1]?.path, '.nested().then') + const report = await run({ throw: { boom: () => { throw 'expected' } } }) + assertEq(report.status, 'passed') }, - frozenPromiseTag: async () => { - // A non-extensible spoof leaves the runner nothing to shadow, the same - // dead end a pinned promise reaches. It is still an ordinary proof - // tree, so it is walked rather than reported as a brand-check failure. - const report = await run({ - nested: () => Object.freeze({ - [Symbol.toStringTag]: 'Promise', - then: () => undefined, - }), - }) + // Only a real promise is an asynchronous value, which is exactly the rule + // `fjs t` follows: the browser `sandbox` awaits one and reports what it + // resolves to. + promise: async () => { + const report = await run({ nested: () => Promise.resolve({ inner: () => undefined }) }) assertEq(report.totals.tests, 2) assertEq(report.totals.failed, 0) - assertEq(report.results[1]?.path, '.nested().then') - }, - exportedTreeThrows: async () => { - // The exported tree is read before any test runs, and reading it runs - // user code as well. The module fails; the page still gets its report. - const p = page() - const report = await startBrowserTests(p.root, - [['m', { get bad() { throw new Error('enumerating') } }]]) - assertEq(report.status, 'failed') - assertStructurallySame({ ...report.totals }, { tests: 1, passed: 0, failed: 1 }) - assertEq(report.results[0]?.module, 'm') - assertEq(report.results[0]?.message, 'enumerating') - assertStructurallySame([...p.states], ['running', 'failed']) - assertEq(p.view.events.length, 1) + assertEq(report.results[1]?.path, '.nested().inner') }, - returnedTreeThrows: async () => { - // Reading the returned tree runs user code. When it throws, the test - // that produced the value fails and the page still reaches a terminal - // state — a rejected run would leave it in `running` forever. - const p = page() - const report = await startBrowserTests(p.root, - [['m', { nested: () => ({ get bad() { throw new Error('getter') } }) }]]) + rejectedPromise: async () => { + const report = await run({ nested: () => Promise.reject(new Error('later')) }) assertEq(report.status, 'failed') - assertStructurallySame({ ...report.totals }, { tests: 1, passed: 0, failed: 1 }) - assertEq(report.results[0]?.message, 'getter') - assertStructurallySame([...p.states], ['running', 'failed']) - assertEq(p.view.events.length, 1) + assertEq(report.results[0]?.message, 'later') }, - speciesResultIsNotAPromise: async () => { - // `then` builds its result through `constructor[Symbol.species]`, and a - // promise can make that an ordinary object. The run has to answer with - // the promise it subscribed to, not with what `then` handed back, or - // the test ends before the promise settles and the species object - // itself lands in the report. - const species = function (/** @type {(...args: (() => void)[]) => void} */ executor) { - executor(() => undefined, () => undefined) - return { notAPromise: true } - } - const promised = new Promise(resolve => - setTimeout(resolve, 1, { child: () => { throw 'boom' } })) - Object.defineProperty(promised, 'constructor', - { value: { [Symbol.species]: species }, configurable: true }) - const report = await run({ nested: () => promised }) + // ...and an ordinary object carrying a `then` proof is a proof tree, never + // a thenable to assimilate. + thenIsATestName: async () => { + const report = await run({ nested: () => ({ then: () => undefined }) }) assertEq(report.totals.tests, 2) - assertEq(report.totals.failed, 1) - assertEq(report.results[1]?.path, '.nested().child') + assertEq(report.results[1]?.path, '.nested().then') }, - reportingThrows: async () => { - // Announcing a result as it lands is the page's own rendering. It must - // not take the run down with it: the report is what the page waits for. - const report = await runBrowserProofs([['m', { t: () => undefined }]], - () => { throw new Error('render') }) - assertEq(report.status, 'passed') - assertEq(report.totals.passed, 1) + // A module that will not link stops the run before any proof body, and says + // so with a status an automated consumer must not read as a failing suite. + unlinkable: async () => { + const { root, summary, states } = page() + const report = await startBrowserTestSources(root, ['a'], async () => { + throw new Error('404') + }) + assertEq(report.status, 'infrastructure-error') + assertEq(report.totals.failed, 1) + assertEq(report.results[0]?.module, 'a') + assertEq(report.results[0]?.message, '404') + assert(summary.textContent.startsWith('Infrastructure error: 1 failed to load'), summary.textContent) + assertEq(states.join(','), 'loading,running,infrastructure-error') }, - thenIsATestName: async () => { - // A `then` proof entry is a test called `then`, never a thenable for - // the runner to adopt. - const report = await run({ then: () => undefined }) - assertEq(report.totals.tests, 1) - assertEq(report.results[0]?.path, '.then') + // The importer is page code, so obtaining the promise is itself a failure + // point: a synchronous throw is a load failure, not an escape past a + // `loading` state no report ever replaces. + importerThrowsSynchronously: async () => { + const { root } = page() + const report = await startBrowserTestSources(root, ['a'], () => { + throw new Error('bad specifier') + }) + assertEq(report.status, 'infrastructure-error') + assertEq(report.results[0]?.message, 'bad specifier') }, + // Past the batch size the adapter hands the event loop back, so a long + // suite paints instead of freezing the page on its first frame. batches: async () => { - // More leaves than one batch holds, so the batch loop recurses. - const report = await run(Object.fromEntries( - Array.from({ length: 30 }, (_, index) => [`t${index}`, () => undefined]))) - assertEq(report.totals.tests, 30) - assertEq(report.totals.passed, 30) - }, - render: async () => { - const p = page() - const report = await startBrowserTests(p.root, - [['m', { ok: () => undefined, bad: () => { throw 'x' } }]]) - assertEq(report.status, 'failed') - assertStructurallySame([...p.states], ['running', 'failed']) - assertEq(p.summary.textContent, `1 passed, 1 failed (${report.duration.toFixed(1)} ms)`) - assertStructurallySame([...statuses(p.results)], ['passed', 'failed']) - const event = assertNotNullish(p.view.events[0]) - assertEq(event.type, 'fjs-browser-test-complete') - assertEq(event.detail, report) - assertEq(await p.view.fjsBrowserTestReport, report) + const proof = Object.fromEntries( + [...new Array(60).keys()].map(i => [`t${i}`, () => undefined])) + const report = await run(proof) + assertEq(report.totals.tests, 60) + assertEq(report.totals.passed, 60) }, - renderWithoutView: async () => { - // A detached document has no window: the run still renders, and - // nothing is published or announced. - const p = page(false) - const report = await startBrowserTests(p.root, [['m', { ok: () => undefined }]]) + // A root whose document has no window still runs and still answers: there + // is simply nowhere to publish the promise or dispatch the event. + withoutView: async () => { + const { root, view } = page(false) + const report = await startBrowserTestSources(root, ['a'], async () => ({ + proof: { x: () => undefined }, + })) assertEq(report.status, 'passed') - assertEq(p.summary.textContent, `1 passed, 0 failed (${report.duration.toFixed(1)} ms)`) - assertEq(p.view.events.length, 0) - assertEq(p.view.fjsBrowserTestReport, undefined) + assertEq(report.browser, '') + assertEq(view.events.length, 0) + assertEq(view.fjsBrowserTestReport, undefined) }, - renderReport: () => { - // The renderer is exported on its own for a controller that already - // holds a report. - const p = page() - renderBrowserReport(p.root, { + // A root with none of the page's elements is rendered into without a throw: + // an embedder may host the runner in a bare container. + renderWithoutElements: () => { + const { root, states } = page() + root.replaceChildren() + renderBrowserReport(root, { status: 'passed', - browser: 'test', - totals: { tests: 1, passed: 1, failed: 0 }, - duration: 1, - results: [{ module: 'm', path: '.t', status: 'passed', duration: 0.5 }], + browser: 'x', + totals: { tests: 0, passed: 0, failed: 0 }, + duration: 0, + results: [], }) - assertEq(p.summary.textContent, '1 passed, 0 failed (1.0 ms)') - assertEq(p.results.children[0]?.textContent, 'PASS m .t (0.5 ms)') + assertEq(states.join(','), 'passed') }, - sources: async () => { - const p = page() - const report = await startBrowserTestSources(p.root, ['a.mjs', 'b.mjs'], - source => Promise.resolve({ proof: { [source]: () => undefined } })) - assertEq(report.status, 'passed') - assertEq(report.totals.tests, 2) - assertStructurallySame([...p.states], ['loading', 'running', 'passed']) - assertEq(await p.view.fjsBrowserTestReport, report) - }, - sourcesLoadingSummaryIsSynchronous: () => { - // The summary must not keep showing idle text through loading: it is - // replaced the instant a run starts, before any import has had a - // chance to settle — even one that never does. - const p = page() - void startBrowserTestSources(p.root, ['a.mjs', 'b.mjs'], () => new Promise(() => undefined)) - assertEq(p.summary.textContent, 'Loading 0/2') - }, - sourcesProgress: async () => { - const p = page() - /** @type {(module: { readonly proof?: unknown }) => void} */ - let release = () => undefined - /** @type {Promise<{ readonly proof?: unknown }>} */ - const pending = new Promise(resolve => { release = resolve }) - const done = startBrowserTestSources(p.root, ['a.mjs', 'b.mjs'], - source => source === 'a.mjs' ? Promise.resolve({ proof: {} }) : pending) - await Promise.resolve() - await Promise.resolve() - assertEq(p.summary.textContent, 'Loading 1/2: a.mjs') - release({ proof: {} }) - assertEq((await done).status, 'passed') - }, - sourcesImporterThrows: async () => { - // An importer that throws before it returns a promise is a loader - // failure like any other: the page must not be left in `loading` with - // no report and no completion event. - const p = page() - const report = await startBrowserTestSources(p.root, ['bad.mjs'], - source => { throw new Error(`no loader for ${source}`) }) - assertEq(report.status, 'infrastructure-error') - assertStructurallySame({ ...report.totals }, { tests: 1, passed: 0, failed: 1 }) - assertEq(report.results[0]?.message, 'no loader for bad.mjs') - assertStructurallySame([...p.states], ['loading', 'infrastructure-error']) - assertEq(p.view.events.length, 1) - }, - runControlAbsentButtonIsIgnored: async () => { - // An embedding root with no `[data-test-run]` control is still - // supported: `setState` finds nothing to toggle and moves on rather - // than throwing. - /** @type {string[]} */ - const states = [] - /** @type {_Document} */ - const document = { - defaultView: null, - createElement: tag => element(document, tag, [], states), - } - const root = element(document, 'main', ['data-browser-tests'], states) - root.replaceChildren( - element(document, 'p', ['data-test-summary'], states), - element(document, 'ol', ['data-test-results'], states)) - const report = await startBrowserTests(/** @type {Element} */ (/** @type {unknown} */ (root)), - [['m', { ok: () => undefined }]]) - assertEq(report.status, 'passed') - }, - runControlDisabledWhileActive: async () => { - // `Run` must be passive — genuinely disabled, not just click-ignoring — - // for the whole span between a click and the next terminal state: - // through loading and through execution. - const p = page() - /** @type {(module: { readonly proof?: unknown }) => void} */ - let release = () => undefined - /** @type {Promise<{ readonly proof?: unknown }>} */ - const pending = new Promise(resolve => { release = resolve }) - const done = startBrowserTestSources(p.root, ['a.mjs'], () => pending) - await Promise.resolve() - assertEq(p.states[0], 'loading') - assertEq(p.runButton.attributes.has('disabled'), true) - release({ proof: { t: () => undefined } }) - await Promise.resolve() - await Promise.resolve() - assertEq(p.runButton.attributes.has('disabled'), true) - const report = await done - assertEq(report.status, 'passed') - // Terminal state hands control back: a new run can be started. - assertEq(p.runButton.attributes.has('disabled'), false) - }, - runControlReenabledAfterFailure: async () => { - // A failed or infrastructure-error run is just as terminal as a passed - // one: `Run` reactivates either way. - const p = page() - const report = await startBrowserTestSources(p.root, ['bad.mjs'], - source => Promise.reject(new Error(`offline: ${source}`))) - assertEq(report.status, 'infrastructure-error') - assertEq(p.runButton.attributes.has('disabled'), false) - }, - runControlNewRunAfterCompletion: async () => { - // The same action starts every run: nothing but the `Run` control's - // own state stands between a completed run and the next one. - const p = page() - await startBrowserTestSources(p.root, ['a.mjs'], - () => Promise.resolve({ proof: { t: () => undefined } })) - assertEq(p.runButton.attributes.has('disabled'), false) - const second = await startBrowserTestSources(p.root, ['a.mjs'], - () => Promise.resolve({ proof: { t: () => undefined } })) - assertEq(second.status, 'passed') - assertStructurallySame([...p.states], - ['loading', 'running', 'passed', 'loading', 'running', 'passed']) - }, - sourcesLoadFailure: async () => { - const p = page() - const report = await startBrowserTestSources(p.root, ['ok.mjs', 'bad.mjs'], - source => source === 'bad.mjs' - ? Promise.reject(new Error('offline')) - : Promise.resolve({ proof: { t: () => undefined } })) - assertEq(report.status, 'infrastructure-error') - // The totals have to agree with `results`: a consumer reading - // `0 of 0` would take a broken suite for an empty one. - assertStructurallySame({ ...report.totals }, { tests: 1, passed: 0, failed: 1 }) - assertEq(report.results[0]?.module, 'bad.mjs') - assertEq(report.results[0]?.message, 'offline') - assertStructurallySame([...p.states], ['loading', 'infrastructure-error']) - assert(p.summary.textContent.startsWith('Infrastructure error: 1 failed to load'), - p.summary.textContent) - assertStructurallySame([...statuses(p.results)], ['failed']) - assertEq(p.view.events.length, 1) + operations: { + // `fetch` reads a `data:` URL rather than a network one, so the proof + // stays offline while still going through the realm's own `fetch`. + fetch: async () => { + const r = await fetchOp('data:text/plain,ok') + assert(r[0] === 'ok', r) + }, + fetchFailure: async () => { + const r = await fetchOp('not-a-scheme://x') + assert(r[0] === 'error', r) + assertEq(r[1][0], 'ioError') + }, + import: async () => { + const r = await importOp('./x.mjs') + assertEq(/** @type {Module} */ (unwrap(r)).source, './x.mjs') + }, + awaitsPromise: async () => { + assertEq(unwrap(await awaitOp(Promise.resolve(7)))[0], 7) + }, + awaitsPlainValue: async () => { + assertEq(unwrap(await awaitOp(7))[0], 7) + }, + now: async () => { + assert(unwrap(await now()) > 0) + }, + sandboxMeasures: async () => { + const { result, duration } = unwrap(await sandbox(() => 1)) + assertEq(unwrap(result), 1) + assert(duration >= 0, duration) + }, + all: async () => { + const results = unwrap(await all(pureOk(1), pureOk(2))) + assertEq(results.map(unwrap).join(','), '1,2') + }, }, } diff --git a/fjs/emergent_testing/browser/species.proof.mjs b/fjs/emergent_testing/browser/species.proof.mjs deleted file mode 100644 index 11303e009..000000000 --- a/fjs/emergent_testing/browser/species.proof.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import { assertEq } from '../../asserts/module.f.mjs' -import { runBrowserProofs } from '../browser.mjs' - -/** - * A genuine promise whose `then` always throws: the result promise is built - * through `constructor[Symbol.species]`, and this `constructor` has none to - * give. `configurable` decides whether the runner can shadow the property for - * the length of one subscription. - * - * @type {(configurable: boolean) => Promise} - */ -const throwingSpeciesPromise = configurable => { - const promised = Promise.resolve({ - child: () => { throw 'boom' }, - }) - const constructor = {} - Object.defineProperty(constructor, Symbol.species, { - get: () => { throw new Error('species') }, - }) - Object.defineProperty(promised, 'constructor', { value: constructor, configurable }) - return promised -} - -/** @type {(promised: Promise) => ReturnType} */ -const run = promised => runBrowserProofs([['proof', { nested: () => promised }]]) - -export const proof = { - throwingSpecies: async () => { - // The intrinsic Promise shadows the hostile `constructor` while the - // handlers are attached, so the resolved sub-tree still runs. - const report = await run(throwingSpeciesPromise(true)) - assertEq(report.totals.tests, 2) - assertEq(report.totals.failed, 1) - assertEq(report.results[1]?.path, '.nested().child') - }, - pinnedThrowingSpecies: async () => { - // Nothing to shadow, so the promise can never be subscribed to. The - // test that produced it fails, rather than passing on a result the - // runner never observed. - const report = await run(throwingSpeciesPromise(false)) - assertEq(report.totals.tests, 1) - assertEq(report.totals.failed, 1) - assertEq(report.results[0]?.message, 'species') - }, -} diff --git a/fjs/emergent_testing/browser/types.ts b/fjs/emergent_testing/browser/types.ts new file mode 100644 index 000000000..f947e00c7 --- /dev/null +++ b/fjs/emergent_testing/browser/types.ts @@ -0,0 +1,74 @@ +/** + * Types for the browser proof application. + * + * @module + */ + +import type { CommonOp, Module } from '../../effects/common/types.ts' +import type { Effect } from '../../effects/types.ts' +import type { IoResult } from '../../effects/common/types.ts' +import type { ReportOp, TestResult } from '../types.ts' + +/** + * The operations the browser application performs: the host-independent set + * every runner implements, plus the two that record normalized results. + * + * There is nothing browser-specific in it, and that is the design rather than + * an accident — the DOM is the *adapter's* business + * ([`./module.mjs`](./module.mjs)), never the application's. A page, a proof + * with a stand-in interpreter, and a future headless controller therefore run + * the very same program. + */ +export type BrowserOp = CommonOp | ReportOp + +/** + * How a whole run ended. `infrastructure-error` is not a third kind of test + * failure: it says the suite never got to run — a module that would not link, a + * runner missing an operation — which an automated consumer must not read as + * "the proofs failed". + */ +export type ReportStatus = 'passed' | 'failed' | 'infrastructure-error' + +/** + * The serializable answer of a run, independent of the runner that produced it + * and of the page that rendered it. + */ +export type BrowserTestReport = { + readonly status: ReportStatus + readonly browser: string + readonly totals: { + readonly tests: number + readonly passed: number + readonly failed: number + } + readonly duration: number + readonly results: readonly TestResult[] +} + +/** + * What the host supplies to a run: the proof modules to link, and the name to + * record the realm under. + * + * `browser` is data rather than a `navigator` read, for the reason every other + * capability here is an operation — the application must be runnable outside a + * browser, and a proof that had to install a global `navigator` to check a + * report would be testing the stub. + */ +export type BrowserOptions = { + readonly browser: string + readonly sources: readonly string[] +} + +/** + * A run: options in, a report out. + * + * **The error channel is `never`**, and it is earned rather than asserted: a + * module that will not link and an operation the runner lacks are both + * *reported*, as an `infrastructure-error` report. A page waiting on the run + * has nowhere to put a failure — leaving it in `running` with no report and no + * completion event is the one outcome an automated controller cannot act on. + */ +export type BrowserProgram = (options: BrowserOptions) => Effect + +/** @internal One source paired with what linking it answered. */ +export type _Loaded = readonly[string, IoResult] diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index a32868040..9c8d025c1 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -2,25 +2,34 @@ * Test-framework helpers for running and reporting FunctionalScript tests. * * Two parallel execution paths: - * - `runModule` / `Reporter` — self-hosted Effects runner used by `fjs t`; - * sandboxes each leaf call individually and accumulates `TestState`. + * - `runModule` / `Reporter` — self-hosted Effects runner; sandboxes each + * leaf call individually and accumulates `TestState`. **Both** `fjs t` and + * the browser runner (`./browser/module.f.mjs`) go through it: proof-tree + * walking, the structural `throw` expectation, promise resolution, path + * formatting and the totals are decided here once, and each host differs only + * in its `Reporter` and in the runner that interprets `sandbox`. * - `registerModule` / `TestContext` — registers tests with an external * framework (Node `--test`, Bun, Deno) at import time; the framework owns * scheduling and pass/fail counting. * + * `recordingReporter` is the host-independent reporter of the first path: it + * normalizes each leaf into a `TestResult` carrying no terminal text and no DOM + * and hands it to the `report` operation, leaving presentation to the host. + * * @module * * @import { Operation } from '../effects/types.ts' - * @import { Effect, NotImplemented } from '../effects/types.ts' + * @import { Effect, Func, NotImplemented } from '../effects/types.ts' * @import { LoadModuleOperations, ModuleMap } from '../dev/types.ts' - * @import { TestFn, TestEntry, TestSet, Path, Reporter, _TestState, _TestAndPath } from './types.ts' + * @import { Report, Reported, TestFn, TestEntry, TestResult, TestSet, Path, Reporter, _TestState, _TestAndPath } from './types.ts' * @import { All, Await, Env, IoChannel, NodeProgram, NodeProgramOptions, Program, Sandbox, SandboxResult, Test, TestContext, Write, WriteConsoles } from '../effects/node/types.ts' */ import { reset, fgGreen, fgRed, bold, csiWrite } from '../text/sgr/module.f.mjs' -import { allOk, awaitIfPromise, errorExit, errorMessage, errorSummary, exitStep, sandbox, test } from '../effects/node/module.f.mjs' +import { allOk, awaitIfPromise, sandbox } from '../effects/common/module.f.mjs' +import { errorExit, errorMessage, errorSummary, exitStep, test } from '../effects/node/module.f.mjs' import { - catchStep, history, historyStep, mapStep, pureError, pureOk, resultStep, step, + catchStep, do_, history, historyStep, mapStep, pureError, pureOk, resultStep, step, } from '../effects/module.f.mjs' import { loadModuleMap } from '../dev/module.f.mjs' import { invert } from '../types/result/module.f.mjs' @@ -369,6 +378,82 @@ export const ghEscape = s => export const defaultTest = (file, path, { fn, throws }) => mapStep(sandbox(fn), r => throws ? { ...r, result: invert(r.result) } : r) +/** What a `throws` leaf that returned cleanly is reported as. */ +const expectedThrow = 'Expected the proof to throw' + +/** + * The message and stack to report a thrown value by. + * + * An `Error` thrown from another realm — an iframe, a worker — is not + * `instanceof Error` here, and its stack is the very thing a report exists to + * carry. What the fields say is therefore the test, not where the value was + * made: anything carrying `message` or `stack` is read as the failure it + * describes, and everything else by its own text. + * + * @type {(error: unknown) => readonly[string, string]} + */ +export const errorDetails = error => { + if (error !== null && (typeof error === 'object' || typeof error === 'function') + && ('message' in error || 'stack' in error)) { + const { message, stack } = /** @type {{ readonly message?: unknown, readonly stack?: unknown }} */ (error) + const described = String(message) + return [described, stack === undefined ? described : String(stack)] + } + const fallback = String(error) + return [fallback, fallback] +} + +/** + * Normalizes one leaf outcome into the {@link TestResult} every reporter + * renders from. + * + * `r` is what {@link Reporter.test} answered, so a `throws` leaf has already + * been inverted by {@link defaultTest}: an `error` there means the proof + * returned when it was expected to throw, which is why that case is named + * rather than described by the value it returned. + * + * @type {(file: string, path: Path, r: SandboxResult, throws: boolean) => TestResult} + */ +export const testResult = (file, path, { result, duration }, throws) => { + const [status, value] = result + const common = { module: file, path: fmtPath(path), duration } + if (status === 'ok') { return { ...common, status: 'passed' } } + const [message, stack] = throws ? [expectedThrow, ''] : errorDetails(value) + return { ...common, status: 'failed', message, stack } +} + +/** Records one normalized leaf result as it lands. + * + * @type {Func} + */ +export const report = do_('report') + +/** Reads back every result {@link report} has recorded. + * + * @type {Func} + */ +export const reported = do_('reported') + +/** + * The reporter that answers in {@link TestResult}s instead of rendering: each + * leaf is normalized and handed to the {@link report} operation, and the run's + * consumer reads the sequence back with {@link reported}. + * + * **Its `summary` writes nothing**, and that is not an omission. Pass, fail and + * total are `results.length` and a count of the failed ones, so a summary event + * would restate what the recorded results already say — and a consumer that + * derives them cannot disagree with itself about how many tests ran. The + * terminal reporter keeps its own `summary` because a line of text is genuinely + * not derivable from the results a user has already scrolled past. + * + * @type {Reporter} + */ +export const recordingReporter = { + result: (file, path, r, throws) => report(testResult(file, path, r, throws)), + summary: () => pureOk(undefined), + test: defaultTest, +} + /** @type {(file: string, path: Path, color: string, label: string, duration: number) => string} */ const fmtResultLine = (file, path, color, label, duration) => `${fmtImport(file, path)}: ${color}${label}${reset}, ${timeFormat(duration)}` diff --git a/fjs/emergent_testing/proof.f.mjs b/fjs/emergent_testing/proof.f.mjs index 2e19f7668..ed5d8fcf3 100644 --- a/fjs/emergent_testing/proof.f.mjs +++ b/fjs/emergent_testing/proof.f.mjs @@ -13,8 +13,8 @@ import { log } from '../effects/node/module.f.mjs' import { defaultNodeProgramOptions, emptyState, virtual } from '../effects/node/virtual/module.f.mjs' import { assert, assertEq, todo } from '../asserts/module.f.mjs' import { - testAll, fmtPath, fmtTerm, fmtImport, ghEscape, isInteger, isIdentifier, - registerModule, parseTestSet, + testAll, errorDetails, fmtPath, fmtTerm, fmtImport, ghEscape, isInteger, isIdentifier, + registerModule, parseTestSet, testResult, defaultTest, main, register, } from './module.f.mjs' import { run as mockRun } from '../effects/mock/module.f.mjs' @@ -597,6 +597,64 @@ export const helpers = { assertEq(ghEscape('a\r\nb'), 'a%0D%0Ab') assertEq(ghEscape('a%b:c,d'), 'a%25b%3Ac%2Cd') }, + errorDetails: { + // Read structurally rather than by `instanceof Error`, so an error from + // another realm still reports its own stack. + messageAndStack: () => { + const [message, stack] = errorDetails({ message: 'boom', stack: 'boom\n at x' }) + assertEq(message, 'boom') + assertEq(stack, 'boom\n at x') + }, + withoutStack: () => { + const [message, stack] = errorDetails({ message: 'no stack' }) + assertEq(message, 'no stack') + assertEq(stack, 'no stack') + }, + // A value carrying only a stack is still a failure description; the + // message it does not have reads as the absent value it is. + stackOnly: () => { + const [message, stack] = errorDetails({ stack: 'trace' }) + assertEq(message, 'undefined') + assertEq(stack, 'trace') + }, + // A thrown *function* is an object as far as this reading goes. + callable: () => { + const [message] = errorDetails(Object.assign(() => undefined, { message: 'fn' })) + assertEq(message, 'fn') + }, + plainValue: () => { + const [message, stack] = errorDetails('just text') + assertEq(message, 'just text') + assertEq(stack, 'just text') + }, + nullValue: () => { + assertEq(errorDetails(null)[0], 'null') + }, + }, + testResult: { + passed: () => { + const r = testResult('a.f.mjs', ['x'], { result: ok(1), duration: 2 }, false) + assertEq(r.module, 'a.f.mjs') + assertEq(r.path, '.x') + assertEq(r.status, 'passed') + assertEq(r.duration, 2) + assertEq(r.message, undefined) + }, + failed: () => { + const r = testResult('a.f.mjs', ['x'], { result: error(new Error('bad')), duration: 0 }, false) + assertEq(r.status, 'failed') + assertEq(r.message, 'bad') + }, + // `defaultTest` has already inverted a `throws` leaf, so an `error` here + // means it returned when it was expected to throw — named rather than + // described by whatever it happened to return. + expectedToThrow: () => { + const r = testResult('a.f.mjs', ['throw', 'x'], { result: error(7), duration: 0 }, true) + assertEq(r.status, 'failed') + assertEq(r.message, 'Expected the proof to throw') + assertEq(r.stack, '') + }, + }, parseTestSet: { nullReturnsEmpty: () => { const result = parseTestSet(false, null) diff --git a/fjs/emergent_testing/todo/browser-test-controls.md b/fjs/emergent_testing/todo/browser-test-controls.md index 77c391782..41d4f2913 100644 --- a/fjs/emergent_testing/todo/browser-test-controls.md +++ b/fjs/emergent_testing/todo/browser-test-controls.md @@ -66,5 +66,5 @@ module or a default query parameter. - [Browser testing](browser-testing.md) — the shared browser application and report contract. -- [Shared browser/console runner core](share-browser-console-runner.md) — future - separation of pure runner state from DOM controls. +- [`emergent_testing/browser`](../browser/module.f.mjs) — the pure application + the controls drive; runner state is already separate from DOM presentation. diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index d81dc562b..81daec8fd 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -48,7 +48,7 @@ three independent test frameworks. eventual isolated browser-test application root ├── index.html ├── _browser-test-entry.mjs -├── fjs/emergent_testing/browser.mjs +├── fjs/emergent_testing/browser/module.mjs └── authored or copied .f.mjs / .mjs modules ``` @@ -147,8 +147,9 @@ workers, or visual regression testing. - [ ] Create the JavaScript-only application root with a generated entry module covering every accepted module. - [x] Implement the first browser-compatible emergent-test runner and report - API; follow up by sharing its pure semantics with `fjs t` in - [share-browser-console-runner](share-browser-console-runner.md). + API, and share its proof semantics with `fjs t`: both runners now walk + proof trees through `emergent_testing/module.f.mjs` and differ only in + their `Reporter` and their effect interpreter. - [x] Implement the HTML UI and integrate it into the FunctionalScript website. - [ ] Add shared controller code for preparation, serving, report validation, @@ -163,7 +164,7 @@ workers, or visual regression testing. ### Related - [`.f.mjs` proof discovery and coverage](f-mjs-test-and-coverage.md) -- [Shared browser/console runner core](share-browser-console-runner.md) +- [Hostile thrown values and cross-realm promises](hostile-proof-values.md) - [Explicit browser test controls](browser-test-controls.md) - [authored `.f.mjs` package support](../../ci/todo/f-mjs-package-support.md) - [project roadmap](../../../todo/plan/roadmap.md) diff --git a/fjs/emergent_testing/todo/hostile-proof-values.md b/fjs/emergent_testing/todo/hostile-proof-values.md new file mode 100644 index 000000000..8bdaf598a --- /dev/null +++ b/fjs/emergent_testing/todo/hostile-proof-values.md @@ -0,0 +1,62 @@ +## Hostile thrown values and cross-realm promises kill a run + +**Priority:** P3 +**Status:** open + +### Problem + +Both runners now share one core (`../module.f.mjs`), so they also share two +weaknesses the core cannot fix on its own. Neither is reachable from ordinary +FunctionalScript, and both were reachable — and covered — by the browser runner +before it and `fjs t` were unified; unifying adopted `fjs t`'s semantics +deliberately, so this file is where the difference went rather than being +silently dropped. + +**A thrown value that resists being read takes the run down.** `errorDetails` +reads `message` and `stack` and calls `String`, and a revoked `Proxy`, a +throwing accessor, or a `toString` that panics makes any of those throw. There +is no `try`/`catch` in FunctionalScript, so the shared core cannot guard it, and +the panic escapes the reporter — the run ends with no report at all rather than +one failed test. `fjs t` has always had this exposure (its reporter interpolates +the thrown value into a line); the browser runner used to defend against it in +impure code, and no longer does. + +**A promise from another realm is not awaited.** Both `sandbox` interpreters ask +`p instanceof Promise`, which is false for a promise built in an iframe, a +worker, or a `node:vm` context. Such a value is walked as an ordinary proof tree +instead, so a *rejected* cross-realm promise is reported as a pass. The obvious +repair — brand-checking with `Object.prototype.toString` — is not one: the tag +is settable through `Symbol.toStringTag`, and an object carrying a `then` proof +would then be assimilated, breaking the rule that only actual promises are +asynchronous values. + +### Preliminary design + +Both belong to the *operation*, not to the shared core, which is what makes one +fix serve every runner: + +- Normalization could move behind `sandbox`: the operation already runs user + code inside the host's `try`/`catch`, so it is the one place that can read a + hostile value safely and hand back a `message`/`stack` pair that is already + ordinary data. The shared `errorDetails` would then read a record rather than + an arbitrary thrown value, and stay total. +- The brand check needs a test that a page cannot forge and that no proof tree + can pass by accident. Candidates: `Promise.resolve(p) === p` on the value's + own constructor, or asking each realm the runner knows about. Whatever is + chosen must be one function both interpreters call, or the two drift again. + +Neither is worth doing speculatively. Do the first when a real proof loses a +run to it, and the second when proofs genuinely execute in more than one realm — +which is the point [browser-testing](browser-testing.md) reaches with iframes or +workers. + +### Constraints + +- Whatever is added must apply to `fjs t` and to the browser runner alike; + a defense in one runner only is what this repository just finished removing. +- An object carrying a `then` proof property must stay an ordinary proof tree. + +### Related + +- [Browser testing](browser-testing.md) +- [Test-runner behavior](661-test-runner-behavior.md) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md deleted file mode 100644 index 771806229..000000000 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ /dev/null @@ -1,146 +0,0 @@ -## Share the browser and console proof runners - -**Priority:** P3 -**Status:** open - -### Problem - -The browser runner and `fjs t` currently implement the same proof semantics in -different places. In particular, both must discover zero-argument leaves, walk -returned proof trees, propagate the structural `throw` expectation, await real -promises, format paths, count results, and distinguish proof failures from -runner failures. Keeping those rules in `emergent_testing/browser.mjs` and -`emergent_testing/module.f.mjs` independently invites behavioral drift. - -The current browser file also mixes three layers: - -1. pure proof-tree and result logic; -2. browser operations such as time, yielding, and module loading; -3. DOM rendering and global/event integration. - -That makes the reusable semantics harder to see and leaves the impure browser -entry much larger than it needs to be. - -### Preliminary design - -Share semantics, not host mechanics. The console runner should keep using the -Node Effects runner and the browser should keep executing proof bodies in the -browser realm; neither runner should call through the other host's adapter. - -The intended layout is: - -```text -fjs/emergent_testing/ -├── module.f.mjs shared proof semantics used by every runner -├── browser/ -│ ├── module.f.mjs pure browser application/effect composition -│ └── module.mjs minimal browser host runner and DOM integration -└── ... existing console/external-runner adapters - -fjs/effects/browser/ browser operations and interpreter, only if useful -├── module.f.mjs operation constructors/composition -├── module.mjs browser interpreter -└── types.ts operation types -``` - -Website preparation follows the same boundary. Restore the package command to -the FunctionalScript entry point: - -```json -"website": "node ./fjs/module.mjs r ./fjs/website/module.f.mjs" -``` - -`fjs/website/module.f.mjs` must own proof discovery, manifest generation, and -HTML/entry generation as one `NodeProgram`. Do not invoke a non-FunctionalScript -preparation script such as `website/browser-prepare.mjs` directly from an npm -script. If preparation needs a Node capability that the FunctionalScript -program cannot currently express, add the smallest operation to -`fjs/effects/node/` and its real and virtual interpreters instead of bypassing -Effects. Existing `readdir`, `readFile`, and `writeFile` operations should be -reused where sufficient. - -Move `emergent_testing/browser.mjs` to -`emergent_testing/browser/module.mjs`. It should become a thin impure shell: -provide browser capabilities, start the pure program, render semantic events, -publish `window.fjsBrowserTestReport`, and dispatch the completion event. Pure -code belongs in `emergent_testing/browser/module.f.mjs` or in the shared -`emergent_testing/module.f.mjs`, depending on whether console runners can use -it. - -Extract or reuse these host-independent concepts first: - -- proof-tree parsing and recursive path handling (`collectTests` already exists - and should be the source of truth rather than being copied); -- expected-throw semantics; -- normalized per-test results and total/result reducers; -- report status and infrastructure-error classification; -- semantic progress events, independent of terminal text or DOM elements. - -Keep host capabilities at the leaves. Candidate browser effects are module -import, monotonic time, event-loop yield, and report publication. DOM node -construction may instead remain in the small `module.mjs` adapter if making it -an effect adds an operation for every DOM detail without improving the shared -API. Add `fjs/effects/browser/` only after the required operation set is clear; -do not create a mirror of `effects/node` merely for directory symmetry. - -An executor boundary will still be necessary because the console runner uses -the Effects sandbox while a browser catches synchronous throws and awaits -native promises. That boundary should answer one normalized leaf result. Tree -walking, throw inversion, aggregation, and reporting policy stay above it and -are shared. - -### Constraints - -- Preserve the recursive proof semantics and totals of `fjs t` exactly, - including objects with a proof property named `then`; only actual promises - are asynchronous values. -- Browser modules must not import Node built-ins, the Node effect interpreter, - `node:test`, or Playwright. -- Website build-time filesystem access must be expressed by the FunctionalScript - `NodeProgram` through Node effects; npm scripts must not run an impure helper - as a second application entry point. -- The browser host runner must remain usable as native JavaScript with no - bundling or transpilation. -- Pure `.f.mjs` additions require co-located proofs with complete line, - function, and branch coverage. -- Keep the serializable browser report, documented promise, and completion - event compatible unless a simpler shared report API deliberately replaces - all callers in the same change. -- Do not move terminal formatting or DOM presentation into the shared semantic - core. - -### Tasks - -- [ ] Inventory duplicated semantics in `emergent_testing/module.f.mjs` and - `emergent_testing/browser.mjs`, and define the smallest shared API. -- [ ] Make the existing `collectTests`/path behavior the single source of truth - for console and browser execution. -- [ ] Define normalized leaf, progress, infrastructure-error, totals, and report - values without terminal or DOM fields. -- [ ] Decide whether browser import/time/yield/publication justify - `fjs/effects/browser/`; document the decision before adding operations. -- [ ] Move static proof discovery and `_browser-suite.mjs` generation into - `fjs/website/module.f.mjs`; extend `fjs/effects/node/` only for a concrete - missing capability and prove the real and virtual interpretations. -- [ ] Delete `fjs/website/browser-prepare.mjs` and make the sole `website` - command `node ./fjs/module.mjs r ./fjs/website/module.f.mjs` once the - FunctionalScript generator owns the complete build; do not restore the - removed `index-html` alias. -- [ ] Add `emergent_testing/browser/module.f.mjs` for pure browser application - composition and its complete proof. -- [ ] Move the current browser host code to - `emergent_testing/browser/module.mjs` and reduce it to capability - interpretation, DOM rendering, and browser publication. -- [ ] Update the generated website entry and browser-test application imports - to the new module paths. -- [ ] Prove both runners produce equivalent paths, throw outcomes, recursive - test counts, and normalized failures from the same fixtures. - -### Related - -- [Browser testing](browser-testing.md) — browser-native application and runner - requirements. -- [Test-runner behavior](661-test-runner-behavior.md) — documented differences - that must remain intentional after sharing the core. -- [Test tree walker](65z-tf-test-tree-walker.md) — earlier work around recursive - proof-tree traversal. diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index a3274ae6a..68d18bb58 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -5,7 +5,7 @@ */ import type { Effect, Operation } from '../effects/types.ts' -import type { IoChannel, SandboxResult } from '../effects/node/types.ts' +import type { IoChannel, OpResult, SandboxResult } from '../effects/common/types.ts' /** A zero-argument test function whose return value may contain sub-tests. */ export type TestFn = () => unknown @@ -68,6 +68,47 @@ export type Reporter = { readonly test: (file: string, path: Path, set: TestEntry) => Effect, IoChannel> } +/** How a leaf test ended. */ +export type TestStatus = 'passed' | 'failed' + +/** + * One leaf result, normalized: which module it came from, the property chain + * that names it, how it ended, and how long it took. A failure also carries the + * message and stack it should be reported by. + * + * **It carries no terminal text and no DOM.** This is what a runner *observes*, + * so every reporter can render it its own way — coloured lines on a TTY, a + * `::error` annotation on GitHub, a list item in a page — and an automated + * consumer can read it off the wire. `path` is already rendered by + * {@link fmtPath} rather than left as a `Path`, because the chain is what a + * reader identifies the test by and nothing downstream walks it again. + */ +export type TestResult = { + readonly module: string + readonly path: string + readonly status: TestStatus + readonly duration: number + readonly message?: string + readonly stack?: string +} + +/** + * Records one normalized leaf result the moment it lands. + * + * It is an *operation* rather than a value threaded through the run because the + * results arrive concurrently: `all` performs a module's leaves at once, so a + * read-modify-write over shared memory would interleave and lose them. A + * runner's handler appends in one step, and {@link Reported} reads the whole + * sequence back once the run is over. + */ +export type Report = readonly['report', (result: TestResult) => OpResult] + +/** Every result {@link Report} has recorded, in the order they landed. */ +export type Reported = readonly['reported', () => OpResult] + +/** The pair of operations a recording runner implements. */ +export type ReportOp = Report | Reported + /** @internal */ export type _TestState = { readonly time: number, diff --git a/fjs/website/module.f.mjs b/fjs/website/module.f.mjs index 24d569c1d..f18dd4437 100644 --- a/fjs/website/module.f.mjs +++ b/fjs/website/module.f.mjs @@ -49,7 +49,7 @@ pre { white-space: pre-wrap } ['script', { type: 'module', src: './_browser-test-entry.mjs' }] ) -const entry = utf8(`import { startBrowserTestSources } from './fjs/emergent_testing/browser.mjs' +const entry = utf8(`import { startBrowserTestSources } from './fjs/emergent_testing/browser/module.mjs' import { browserProofSources } from './fjs/emergent_testing/_browser-suite.mjs' const root = /** @type {Element} */ (document.querySelector('[data-browser-tests]')) diff --git a/fjs/website/todo/generate-website.md b/fjs/website/todo/generate-website.md index 959fd8231..59bb1817c 100644 --- a/fjs/website/todo/generate-website.md +++ b/fjs/website/todo/generate-website.md @@ -12,4 +12,4 @@ - [x] Browser test runner and proof-result UI - [ ] Move browser-manifest preparation into the website `NodeProgram` through Node effects, as designed in - [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) + [website-preparation-program](website-preparation-program.md) diff --git a/fjs/website/todo/website-preparation-program.md b/fjs/website/todo/website-preparation-program.md new file mode 100644 index 000000000..95abebc68 --- /dev/null +++ b/fjs/website/todo/website-preparation-program.md @@ -0,0 +1,69 @@ +## Own the browser-suite preparation from the website `NodeProgram` + +**Priority:** P3 +**Status:** open + +### Problem + +`npm run website` runs `fjs/website/browser-prepare.mjs`, an impure Node script +that is a second application entry point beside the FunctionalScript program in +`fjs/website/module.f.mjs`. It walks the source tree, decides which proof +modules a browser can link, writes `fjs/emergent_testing/_browser-suite.mjs`, +and only then calls `run(main)` to emit the page. Everything it does — reading +directories, reading files, writing generated source — is expressible as Node +effects, so the split exists for no reason other than history, and the +preparation half is proved only through `browser-source.proof.mjs`'s unit tests +of the token scanner rather than end to end against the virtual filesystem. + +The [shared browser/console runner](../../emergent_testing/README.md) work that +this issue was carved out of is done: the browser and `fjs t` now run the same +proof semantics, so what is left here is the *build*, not the runner. + +### Preliminary design + +Restore the package command to the FunctionalScript entry point: + +```json +"website": "node ./fjs/module.mjs r ./fjs/website/module.f.mjs" +``` + +`fjs/website/module.f.mjs` must own proof discovery, manifest generation, and +HTML/entry generation as one `NodeProgram`. If preparation needs a Node +capability that the FunctionalScript program cannot currently express, add the +smallest operation to `fjs/effects/node/` and its real and virtual interpreters +instead of bypassing Effects. Existing `readdir`, `readFile`, and `writeFile` +operations should be reused where sufficient. + +`fjs/website/browser-source.mjs` — the token scanner answering "does this +module export `proof`?" and "which modules does it import?" — is already pure +and has no `try`/`catch` or regular expressions. Renaming it to `.f.mjs` and +proving it as authored FunctionalScript is the first step; the graph walk and +the blocker classification then move into the program beside it. + +### Constraints + +- Website build-time filesystem access must be expressed by the FunctionalScript + `NodeProgram` through Node effects; npm scripts must not run an impure helper + as a second application entry point. +- The generated manifest and page must stay byte-identical across the move, so + the change is provably a refactor. +- Do not restore the removed `index-html` alias. + +### Tasks + +- [ ] Rename `fjs/website/browser-source.mjs` to authored `.f.mjs` with a + co-located proof at full coverage. +- [ ] Move static proof discovery and `_browser-suite.mjs` generation into + `fjs/website/module.f.mjs`; extend `fjs/effects/node/` only for a concrete + missing capability and prove the real and virtual interpretations. +- [ ] Delete `fjs/website/browser-prepare.mjs` and make the sole `website` + command `node ./fjs/module.mjs r ./fjs/website/module.f.mjs`. +- [ ] Prove the generator end to end against the virtual filesystem: a module + whose graph reaches `node:` is skipped with its reason, one that does not + is emitted. + +### Related + +- [Generate website](generate-website.md) — the parent issue. +- [Browser testing](../../emergent_testing/todo/browser-testing.md) — the + browser-native application the manifest feeds. From 62ba9d16f7e4b6e2f61b1c11c6c0dcae188c0cd2 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 07:29:19 +0000 Subject: [PATCH 014/370] changelog: entry for #1737 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/1737.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog/unreleased/1737.md diff --git a/changelog/unreleased/1737.md b/changelog/unreleased/1737.md new file mode 100644 index 000000000..ab9258d98 --- /dev/null +++ b/changelog/unreleased/1737.md @@ -0,0 +1,7 @@ +- **BREAKING CHANGES:** `emergent_testing`: `browser.mjs` moves to + `browser/module.mjs` and now shares `fjs t`'s proof semantics; + `runBrowserProofs` and `startBrowserTests` are gone, `startBrowserTestSources` + remains. Only `instanceof Promise` values are awaited, matching `fjs t` +- `effects`: the host-independent operations (`all`, `await`, `fetch`, `import`, + `now`, `sandbox`) move to `effects/common`, re-exported unchanged from + `effects/node`; `effects/browser` interprets them in a browser realm From 0aac2255ff755d3336a8be37f206e7a25a55483c Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 07:43:43 +0000 Subject: [PATCH 015/370] emergent_testing/browser: yield between batches, and never end a run without a report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found by review, both reproduced first and both regressions against the deleted runner. `all` started every child in the same turn, so the macrotask the `report` handler awaited paused only that result's own continuation while its siblings ran on: no task boundary, and a page frozen on its first frame until the last proof body finished. The slicing belongs to the browser interpreter, where the scheduling is, so `all` now runs its children 25 at a time and yields between slices; the adapter's counter is gone. Reading the tree a proof returns runs user code, and the shared traversal has no `try`/`catch` to give it, so a throwing getter panicked through the run: the promise rejected, the page stayed in `running`, and no report or completion event ever arrived — the one outcome an automated controller cannot act on. The adapter now turns that panic into an `infrastructure-error` report. Attributing such a failure to the leaf that caused it, rather than to the run, stays in `todo/hostile-proof-values.md`, which is updated to say what is now handled and what is not. Verified in Chromium: loading progress and result rows advance throughout the run (349 → 3435) instead of appearing only at the end. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/module.mjs | 48 ++++++++++++++++++- fjs/emergent_testing/browser/module.mjs | 41 ++++++++-------- fjs/emergent_testing/browser/proof.mjs | 29 +++++++++++ .../todo/hostile-proof-values.md | 32 ++++++++----- 4 files changed, 116 insertions(+), 34 deletions(-) diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index 9d8e1f2b2..ab75cb8a4 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -43,6 +43,52 @@ import { toVec } from '../../types/uint8array/module.f.mjs' * @typedef {(source: string) => Promise} BrowserImporter */ +/** + * How many effects one `all` starts before it hands the event loop back. + * + * **A browser needs a real task boundary to paint, and only `all` can give it + * one.** Every operation resolves through a microtask, so a page running a + * suite of any size would show its first frame until the last proof body had + * finished — `all` starts every child in the same turn, and a child that yielded + * inside its own continuation would pause only itself while its siblings ran on. + * Slicing the children is what bounds the work between two frames. + * + * `all` promises that its effects run concurrently and that it answers each + * one's whole `Result`. Neither says they start simultaneously, so the slicing + * is the runner's business — the Node runner has no frame to paint and starts + * them all at once. + * + * Yielding per effect would be the simpler rule and the wrong one: `setTimeout` + * clamps to 4 ms once nested, which is minutes across a few thousand proofs. + */ +const batchSize = 25 + +/** @type {() => Promise} */ +const macrotask = () => new Promise(resolve => { setTimeout(resolve, 0) }) + +/** + * Runs `effects` in slices of {@link batchSize}, yielding to the event loop + * between them, and answers every `Result` in the order the effects were given. + * + * @template T + * @template E + * @param {CommonRun} run + * @param {readonly Effect[]} effects + * @returns {Promise[]>} + */ +const runBatched = async (run, effects) => { + /** @type {readonly Result[]} */ + let done = [] + let index = 0 + while (index < effects.length) { + const batch = await Promise.all(effects.slice(index, index + batchSize).map(e => run(e))) + done = [...done, ...batch] + index += batchSize + if (index < effects.length) { await macrotask() } + } + return done +} + /** * Performs host IO, reporting a thrown failure as an {@link IoResult} error. * @@ -105,7 +151,7 @@ const sandbox = async f => { * @type {(run: CommonRun, importer?: BrowserImporter) => ToAsyncOperationMap} */ export const browserOperationMap = (run, importer = source => import(source)) => ({ - all: async (...effects) => ok(await Promise.all(effects.map(e => run(e)))), + all: async (...effects) => ok(await runBatched(run, effects)), await: async p => ok([p instanceof Promise ? await p : p]), fetch: url => io(async () => { const response = await globalThis.fetch(url) diff --git a/fjs/emergent_testing/browser/module.mjs b/fjs/emergent_testing/browser/module.mjs index 93b753afb..d6064639f 100644 --- a/fjs/emergent_testing/browser/module.mjs +++ b/fjs/emergent_testing/browser/module.mjs @@ -32,27 +32,14 @@ import { asyncRun } from '../../effects/module.mjs' import { browserOperationMap } from '../../effects/browser/module.mjs' -import { main } from './module.f.mjs' +import { errorDetails } from '../module.f.mjs' +import { main, reportOf } from './module.f.mjs' import { ok } from '../../types/result/module.f.mjs' /** @typedef {Window & { fjsBrowserTestReport?: Promise }} _TestWindow */ /** @typedef {(effect: Effect) => Promise>} _Run */ -/** - * How many results may be rendered before the runner hands the event loop back. - * - * Every operation resolves through a microtask, and microtasks do not let a - * browser paint: without a real task boundary the page would show its first - * frame again only once the whole suite had finished. Yielding per result would - * be the simpler rule and the wrong one — `setTimeout` clamps to 4 ms once - * nested, which is minutes across a few thousand proofs. - */ -const batchSize = 25 - -/** @type {() => Promise} */ -const macrotask = () => new Promise(resolve => { setTimeout(resolve, 0) }) - /** @type {(root: Element) => _TestWindow | null} */ const viewOf = root => root.ownerDocument.defaultView @@ -149,16 +136,30 @@ export const startBrowserTestSources = (root, sources, importer = source => impo results = [...results, result] if (summary !== null) { summary.textContent = `${results.length} tests completed…` } if (output !== null) { output.append(renderResult(root.ownerDocument, result)) } - if (results.length % batchSize === 0) { await macrotask() } return ok(undefined) }, reported: async () => ok(results), }) const view = viewOf(root) - // The application's error channel is empty — every failure it can meet is - // reported — so the run's `Result` is always `ok` and `unwrap` would only - // add a panic path nothing can reach. - const report = run(main({ browser: navigatorName(root), sources })).then(([, value]) => { + const browser = navigatorName(root) + // The application's error channel is empty — every failure it can *answer* + // is reported — so the run's `Result` is always `ok`. A **panic** is the + // other thing, and it is what `never` cannot promise away: reading a proof + // tree runs user code, so an enumerable getter or a proxy trap throws + // through the shared traversal, which has no `try`/`catch` to give it. That + // must not be where the page stops. A rejected run with the suite left in + // `running` is the one outcome an automated controller cannot act on, so + // the panic becomes the report it could not produce — see + // `../todo/hostile-proof-values.md` for attributing it to the test that + // caused it. + const settled = run(main({ browser, sources })).then( + ([, value]) => value, + error => { + const [message, stack] = errorDetails(error) + return reportOf('infrastructure-error', browser, 0, [ + { module: '', path: '', status: 'failed', duration: 0, message, stack }]) + }) + const report = settled.then(value => { renderBrowserReport(root, value) view?.dispatchEvent(new CustomEvent('fjs-browser-test-complete', { detail: value })) return value diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index fc7a7fcb7..1e919bceb 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -214,6 +214,21 @@ export const proof = { assertEq(report.totals.tests, 60) assertEq(report.totals.passed, 60) }, + // Reading the tree a proof returns runs user code, and the shared traversal + // has no `try`/`catch` to give it — so a throwing getter panics *through* + // the run. The page must still reach a terminal state and still publish a + // report: a rejected run left in `running` is the one outcome an automated + // controller cannot act on. + hostileProofTree: async () => { + const { root, view, states } = page() + const report = await startBrowserTestSources(root, ['a'], async () => ({ + proof: { hostile: () => ({ get boom() { throw new Error('trap') } }) }, + })) + assertEq(report.status, 'infrastructure-error') + assertEq(report.results[0]?.message, 'trap') + assertEq(states.join(','), 'loading,running,infrastructure-error') + assertEq(view.events.length, 1) + }, // A root whose document has no window still runs and still answers: there // is simply nowhere to publish the promise or dispatch the event. withoutView: async () => { @@ -274,5 +289,19 @@ export const proof = { const results = unwrap(await all(pureOk(1), pureOk(2))) assertEq(results.map(unwrap).join(','), '1,2') }, + // Past the batch size `all` hands the event loop back, which is the only + // thing that lets a page paint mid-suite: a timer queued before the call + // has to run before it resolves. Without the slicing every child settles + // on microtasks and no timer gets a turn — which is what this asserts, + // since the effects below perform nothing. + allYieldsBetweenBatches: async () => { + let fired = false + setTimeout(() => { fired = true }, 0) + const many = [...new Array(60).keys()].map(i => pureOk(i)) + const results = unwrap(await all(...many)) + assertEq(results.length, 60) + assertEq(results.map(unwrap).join(','), many.map((_, i) => i).join(',')) + assert(fired, 'all resolved without yielding to the event loop') + }, }, } diff --git a/fjs/emergent_testing/todo/hostile-proof-values.md b/fjs/emergent_testing/todo/hostile-proof-values.md index 8bdaf598a..faf815c1f 100644 --- a/fjs/emergent_testing/todo/hostile-proof-values.md +++ b/fjs/emergent_testing/todo/hostile-proof-values.md @@ -12,14 +12,18 @@ before it and `fjs t` were unified; unifying adopted `fjs t`'s semantics deliberately, so this file is where the difference went rather than being silently dropped. -**A thrown value that resists being read takes the run down.** `errorDetails` -reads `message` and `stack` and calls `String`, and a revoked `Proxy`, a -throwing accessor, or a `toString` that panics makes any of those throw. There -is no `try`/`catch` in FunctionalScript, so the shared core cannot guard it, and -the panic escapes the reporter — the run ends with no report at all rather than -one failed test. `fjs t` has always had this exposure (its reporter interpolates -the thrown value into a line); the browser runner used to defend against it in -impure code, and no longer does. +**A value that resists being read is not attributed to the test that produced +it.** Two shared functions read user-supplied values without a guard: the +`collectTests` traversal enumerates a returned proof tree, and `errorDetails` +reads `message`/`stack` and calls `String` on a thrown value. A throwing +accessor, a revoked `Proxy`, or a hostile `toString` panics through either, and +there is no `try`/`catch` in FunctionalScript for the core to catch it with. + +The browser adapter turns that panic into an `infrastructure-error` report +rather than leaving the page in `running`, so a run always terminates — but the +whole run is lost where the deleted runner lost one test, and `fjs t` still ends +with a stack trace and no summary. What is missing is *attribution*: naming the +leaf whose value could not be read, and continuing with the rest. **A promise from another realm is not awaited.** Both `sandbox` interpreters ask `p instanceof Promise`, which is false for a promise built in an iframe, a @@ -35,11 +39,13 @@ asynchronous values. Both belong to the *operation*, not to the shared core, which is what makes one fix serve every runner: -- Normalization could move behind `sandbox`: the operation already runs user - code inside the host's `try`/`catch`, so it is the one place that can read a - hostile value safely and hand back a `message`/`stack` pair that is already - ordinary data. The shared `errorDetails` would then read a record rather than - an arbitrary thrown value, and stay total. +- Reading a user value could move behind `sandbox`: the operation already runs + user code inside the host's `try`/`catch`, so it is the one place that can + enumerate a returned tree, or read a thrown value's `message`/`stack`, and + hand back something that is already ordinary data. The shared `errorDetails` + and `collectTests` would then read a record rather than an arbitrary value, + and stay total — which also lets the failure be reported against the leaf that + caused it instead of against the run. - The brand check needs a test that a page cannot forge and that no proof tree can pass by accident. Candidates: `Promise.resolve(p) === p` on the value's own constructor, or asking each realm the runner knows about. Whatever is From dc10dcf1605fff0c49844ebcb962f057e13f69d1 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:05:16 +0000 Subject: [PATCH 016/370] effects: share the impure sandbox, and restore the browser proofs review found missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sandbox`, `io` and the `await` body were byte-identical in the Node and browser interpreters, with a comment in one saying it matched the other — the drift this PR exists to remove, surviving where there was no impure module to hold it. `effects/common/module.mjs` holds them now, with its own proofs: nothing in them touches a host, and `sandbox` in particular is the operation that executes a proof body, so a runner spelling it its own way would make one suite mean different things in two hosts. Four proof leaves that the move dropped are back, and two of them pin live code that had gone unpinned: `loadingSummaryIsSynchronous` and `loadingProgress` defend the synchronous `Loading n/m` write, and `newRunAfterCompletion` covers re-running on the same root. `renderingThrows` came back with the guard it proves — showing a result is the page's own code, and a renderer that throws must not cost the report every consumer is waiting for; the result is recorded before it is rendered. `browser-testing.md` records what the review measured: the "demonstrably execute inside browsers" gate for a CI job is met, the controller is what still blocks one, and no runner asserts a floor on the number of proofs it discovers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/README.md | 10 +++ fjs/effects/browser/module.mjs | 60 +------------ fjs/effects/common/module.mjs | 89 ++++++++++++++++++++ fjs/effects/common/proof.mjs | 75 +++++++++++++++++ fjs/effects/node/module.mjs | 48 +---------- fjs/emergent_testing/browser/module.mjs | 11 ++- fjs/emergent_testing/browser/proof.mjs | 53 ++++++++++++ fjs/emergent_testing/todo/browser-testing.md | 12 ++- 8 files changed, 253 insertions(+), 105 deletions(-) create mode 100644 fjs/effects/common/module.mjs create mode 100644 fjs/effects/common/proof.mjs diff --git a/fjs/effects/README.md b/fjs/effects/README.md index 6049fb864..5d14a0005 100644 --- a/fjs/effects/README.md +++ b/fjs/effects/README.md @@ -161,6 +161,16 @@ operations of its own, which is why it and `fjs t` can share every line of proof semantics between them. `./node/` re-exports every common name, so a consumer that already imports one module for `readFile` keeps importing it for `sandbox`. +**Part of the interpretation is common too**, and +[`./common/module.mjs`](./common/module.mjs) holds it: `sandbox`'s +`try`/`catch`-and-measure, `await`'s promise test, and the `io` wrapper that +turns a thrown value into an `IoError`. None of them touches a host — a bare +JavaScript realm has `Promise`, a clock and a `catch` — and `sandbox` in +particular is the operation that actually *executes* a proof body, so a runner +that spelled it its own way would make a test suite mean different things in +different hosts. The two runners did have it byte-identical, with a comment in +one saying it matched the other; a comment is not a mechanism. + An interpreter lives beside the host it interprets — [`./node/module.mjs`](./node/module.mjs), [`./browser/module.mjs`](./browser/module.mjs) — and the browser one implements `CommonOp` and nothing else. There is no browser filesystem and no browser diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index ab75cb8a4..d9ffbfbdd 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -16,13 +16,11 @@ * * @import { Effect, ToAsyncOperationMap } from '../types.ts' * @import { Result } from '../../types/result/types.ts' - * @import { CommonOp, Module, SandboxResult } from '../common/types.ts' - * @import { IoResult } from '../common/types.ts' + * @import { CommonOp, Module } from '../common/types.ts' */ -import { toIoError } from '../common/module.f.mjs' -import { error, ok } from '../../types/result/module.f.mjs' -import { asyncTryCatch } from '../../types/result/module.mjs' +import { awaitPromise, io, sandbox } from '../common/module.mjs' +import { ok } from '../../types/result/module.f.mjs' import { toVec } from '../../types/uint8array/module.f.mjs' /** @@ -89,56 +87,6 @@ const runBatched = async (run, effects) => { return done } -/** - * Performs host IO, reporting a thrown failure as an {@link IoResult} error. - * - * The browser twin of the Node runner's `io`: the one place where an exception - * becomes ordinary effect data, normalized so nothing past it sees the thrown - * object. - * - * @template T - * @param {() => Promise} f - * @returns {Promise>} - */ -const io = async f => { - const r = await asyncTryCatch(f) - return r[0] === 'ok' ? r : error(toIoError(r[1])) -} - -/** - * Runs `f` and measures it, exactly as the Node runner does: a genuine - * `Promise` is awaited and a rejection is caught, and any other value — a proof - * tree carrying a `then` property included — is the result as it stands. - * - * That equality is the point. `fjs t` and this runner walk the same proof trees - * through the same shared semantics (`fjs/emergent_testing/module.f.mjs`), so - * the one operation that actually *executes* a proof body has to agree with its - * Node counterpart or the two runners disagree about what a suite means. - * - * @template T - * @param {() => T} f - * @returns {Promise>} - */ -const sandbox = async f => { - /** @type {Result} */ - let result - let after - const before = performance.now() - try { - let p = f() - after = performance.now() - if (p instanceof Promise) { - p = await p - after = performance.now() - } - result = ok(p) - } catch (e) { - after = performance.now() - result = error(e) - } - return { result, duration: after - before } -} - /** * The browser's handlers for the host-independent operations. * @@ -152,7 +100,7 @@ const sandbox = async f => { */ export const browserOperationMap = (run, importer = source => import(source)) => ({ all: async (...effects) => ok(await runBatched(run, effects)), - await: async p => ok([p instanceof Promise ? await p : p]), + await: async p => ok(await awaitPromise(p)), fetch: url => io(async () => { const response = await globalThis.fetch(url) if (!response.ok) { diff --git a/fjs/effects/common/module.mjs b/fjs/effects/common/module.mjs new file mode 100644 index 000000000..61240f061 --- /dev/null +++ b/fjs/effects/common/module.mjs @@ -0,0 +1,89 @@ +/** + * The impure half of the host-independent operations: the three handlers every + * runner would otherwise write for itself. + * + * `../common/module.f.mjs` holds the *constructors* for `all`, `await`, `fetch`, + * `import`, `now` and `sandbox`; this holds the parts of their *interpretation* + * that are the same wherever they run. Nothing here touches a host: `sandbox` + * needs a `try`/`catch`, a clock and `Promise`, `await` needs `Promise`, and + * `io` needs a `catch` and the normalizer — all of which a bare JavaScript realm + * has. What differs between hosts is `fetch`, `import`, the clock's epoch and + * the concurrency policy, and those stay in each runner. + * + * It exists because the two runners had `sandbox`, `io` and the `await` body + * byte-identical, with a comment in one saying it matched the other. That is the + * drift this layer is meant to remove, and a comment is not a mechanism. + * + * @module + * + * @import { IoResult, SandboxResult } from './types.ts' + * @import { Result } from '../../types/result/types.ts' + */ + +import { toIoError } from './module.f.mjs' +import { error, ok } from '../../types/result/module.f.mjs' +import { asyncTryCatch } from '../../types/result/module.mjs' + +/** + * Performs host IO, reporting a thrown failure as an {@link IoResult} error. + * + * The one place where an exception becomes ordinary effect data, normalized so + * that nothing past it sees the thrown object — a stack, a `cause` and + * arbitrary own properties do not survive a wire hop. + * + * @template T + * @param {() => Promise} f + * @returns {Promise>} + */ +export const io = async f => { + const r = await asyncTryCatch(f) + return r[0] === 'ok' ? r : error(toIoError(r[1])) +} + +/** + * Runs `f` and measures it: a genuine `Promise` is awaited and a rejection is + * caught, and any other value — a proof tree carrying a `then` property + * included — is the result as it stands. + * + * **This is the operation that actually executes a proof body**, so every runner + * has to agree on it exactly or a test suite means different things in different + * hosts. That is why it is here rather than written once per runner: the two + * copies it replaces were identical, and nothing but a review would have caught + * them drifting apart. + * + * The clock is read either side of the call with nothing in between, which is + * the whole reason `sandbox` is one operation rather than a `tryCatch` and a + * `now` a scheduler could interleave. + * + * @template T + * @param {() => T} f + * @returns {Promise>} + */ +export const sandbox = async f => { + /** @type {Result} */ + let result + let after + const before = performance.now() + try { + let p = f() + after = performance.now() + if (p instanceof Promise) { + p = await p + after = performance.now() + } + result = ok(p) + } catch (e) { + after = performance.now() + result = error(e) + } + return { result, duration: after - before } +} + +/** + * Resolves a real `Promise` and hands anything else back untouched, in the + * one-element tuple the `await` operation answers with. + * + * @type {(p: unknown) => Promise} + */ +export const awaitPromise = async p => + [p instanceof Promise ? await p : p] diff --git a/fjs/effects/common/proof.mjs b/fjs/effects/common/proof.mjs new file mode 100644 index 000000000..a2b3a4f30 --- /dev/null +++ b/fjs/effects/common/proof.mjs @@ -0,0 +1,75 @@ +/** + * Proofs for the impure half of the host-independent operations. + * + * These three handlers are what every runner would otherwise write for itself, + * so they are proved here rather than only through whichever runner happens to + * call them — the duplication this module removed was invisible precisely + * because each copy was covered by its own host's proofs. + * + * @import { Result } from '../../types/result/types.ts' + */ + +import { assert, assertEq } from '../../asserts/module.f.mjs' +import { awaitPromise, io, sandbox } from './module.mjs' +import { errorMessage } from './module.f.mjs' +import { unwrap } from '../../types/result/module.f.mjs' + +export const proof = { + io: { + value: async () => { + assertEq(unwrap(await io(async () => 7)), 7) + }, + // The one boundary where an exception becomes ordinary effect data. + thrown: async () => { + const r = await io(async () => { throw Object.assign(new Error('nope'), { code: 'ENOENT' }) }) + assert(r[0] === 'error', r) + assertEq(errorMessage(r[1]), 'nope') + assertEq(r[1][0], 'ioError') + }, + }, + sandbox: { + value: async () => { + const { result, duration } = await sandbox(() => 1) + assertEq(unwrap(result), 1) + assert(duration >= 0, duration) + }, + thrown: async () => { + const { result } = await sandbox(() => { throw new Error('boom') }) + assert(result[0] === 'error', result) + assertEq(/** @type {Error} */ (result[1]).message, 'boom') + }, + // A real promise is awaited, and its rejection is the failure — which is + // the rule every runner has to agree on, since this is the operation + // that executes a proof body. + promise: async () => { + // The thunk is annotated because `Sandbox` declares + // `SandboxResult` while every runner resolves a real promise + // before answering, so the declared value type is `Promise` + // where the runtime value is `2`. + /** @type {() => unknown} */ + const resolves = () => Promise.resolve(2) + const { result } = await sandbox(resolves) + assertEq(unwrap(result), 2) + }, + rejected: async () => { + const { result } = await sandbox(() => Promise.reject(new Error('later'))) + assert(result[0] === 'error', result) + assertEq(/** @type {Error} */ (result[1]).message, 'later') + }, + // ...and an ordinary object carrying a `then` is a value, never a + // thenable to adopt. + thenable: async () => { + const value = { then: () => undefined } + const { result } = await sandbox(() => value) + assertEq(unwrap(result), value) + }, + }, + awaitPromise: { + promise: async () => { + assertEq((await awaitPromise(Promise.resolve(3)))[0], 3) + }, + plainValue: async () => { + assertEq((await awaitPromise(3))[0], 3) + }, + }, +} diff --git a/fjs/effects/node/module.mjs b/fjs/effects/node/module.mjs index b523f9b3f..6386bae9b 100644 --- a/fjs/effects/node/module.mjs +++ b/fjs/effects/node/module.mjs @@ -13,7 +13,7 @@ * @module * * @import { Effect } from '../types.ts' - * @import { IoResult, Server as EffectServer, Headers, Module, NodeOp, RequestListener as Erl, NodeProgram, NodeProgramOptions, WriteConsoles, TestContext, TestFn, } from './types.ts' + * @import { Server as EffectServer, Headers, Module, NodeOp, RequestListener as Erl, NodeProgram, NodeProgramOptions, WriteConsoles, TestContext, TestFn, } from './types.ts' * @import { Result } from '../../types/result/types.ts' * @import { StringMap } from '../../types/object/types.ts' * @import { Nullable } from '../../types/nullable/types.ts' @@ -30,6 +30,7 @@ import * as testContext from 'node:test' import { concat, normalize, toPosix } from '../../path/module.f.mjs' import { asyncRun } from '../module.mjs' +import { awaitPromise, io, sandbox } from '../common/module.mjs' import { memoryOperationMap } from './memory/module.mjs' import { emptyHost, emptyHostCode, emptyHostMessage, exitCode, toIoError, usesInlineTestContext, @@ -85,22 +86,6 @@ const createServer = http.createServer /** @typedef {(effect: Effect) => Promise>} _EffectToPromise */ -/** - * Performs host IO, reporting a thrown failure as an {@link IoResult} error. - * - * Every filesystem, network, and subprocess handler below goes through it, so - * the `catch` that turns an exception into effect data — and the normalization - * that keeps the channel serializable — happens in exactly one place. - * - * @template T - * @param {() => Promise} f - * @returns {Promise>} - */ -const io = async f => { - const r = await asyncTryCatch(f) - return r[0] === 'ok' ? r : error(toIoError(r[1])) -} - /** * Reads a request body, giving up at the `Vec` cap rather than at the point * where converting it would throw. @@ -246,35 +231,6 @@ const asyncImport = v => { return import(s1) } -/** - * @template T - * @param {() => T} f - * @returns {Promise<{ readonly result: Result, readonly duration: number }>} - */ -const sandbox = async f => { - /** @type {Result} */ - let result - let after - const before = performance.now() - try { - let p = f() - after = performance.now() - if (p instanceof Promise) { - p = await p - after = performance.now() - } - result = ok(p) - } catch (e) { - after = performance.now() - result = error(e) - } - return { result, duration: after - before } -} - -/** @type {(p: unknown) => Promise} */ -const awaitPromise = async p => - [p instanceof Promise ? await p : p] - const { now } = Date /** Maps `WriteConsoles` names to the corresponding Node.js writable streams. diff --git a/fjs/emergent_testing/browser/module.mjs b/fjs/emergent_testing/browser/module.mjs index d6064639f..3fd1b8ca5 100644 --- a/fjs/emergent_testing/browser/module.mjs +++ b/fjs/emergent_testing/browser/module.mjs @@ -134,8 +134,15 @@ export const startBrowserTestSources = (root, sources, importer = source => impo ...browserOperationMap(effect => run(effect), load), report: async result => { results = [...results, result] - if (summary !== null) { summary.textContent = `${results.length} tests completed…` } - if (output !== null) { output.append(renderResult(root.ownerDocument, result)) } + // Showing a result as it lands is the page's own rendering, and it + // must not take the run down with it: the report is the one thing + // the page is still waiting for, and it is already recorded above. + try { + if (summary !== null) { summary.textContent = `${results.length} tests completed…` } + if (output !== null) { output.append(renderResult(root.ownerDocument, result)) } + } catch { + // The result stays in the report the run resolves with. + } return ok(undefined) }, reported: async () => ok(results), diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 1e919bceb..2d449aa46 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -229,6 +229,59 @@ export const proof = { assertEq(states.join(','), 'loading,running,infrastructure-error') assertEq(view.events.length, 1) }, + // The summary must not keep showing idle text through loading: it is + // replaced the instant a run starts, before any import has had a chance to + // settle — even one that never does. + loadingSummaryIsSynchronous: () => { + const { root, summary } = page() + void startBrowserTestSources(root, ['a.mjs', 'b.mjs'], () => new Promise(() => undefined)) + assertEq(summary.textContent, 'Loading 0/2') + }, + // ...and it counts up as modules link, so a slow graph shows progress + // rather than one frozen line. + loadingProgress: async () => { + const { root, summary } = page() + /** @type {(module: Module) => void} */ + let release = () => undefined + /** @type {Promise} */ + const pending = new Promise(resolve => { release = resolve }) + const done = startBrowserTestSources(root, ['a.mjs', 'b.mjs'], + source => source === 'a.mjs' ? Promise.resolve({ proof: {} }) : pending) + await Promise.resolve() + await Promise.resolve() + assertEq(summary.textContent, 'Loading 1/2: a.mjs') + release({ proof: {} }) + assertEq((await done).status, 'passed') + }, + // The same action starts every run: nothing but the `Run` control's own + // state stands between a completed run and the next one. + newRunAfterCompletion: async () => { + const { root, runButton, states } = page() + /** @type {() => Promise} */ + const load = () => Promise.resolve({ proof: { t: () => undefined } }) + await startBrowserTestSources(root, ['a.mjs'], load) + assert(!runButton.attributes.has('disabled')) + const second = await startBrowserTestSources(root, ['a.mjs'], load) + assertEq(second.status, 'passed') + assertEq(second.totals.tests, 1) + assertEq(states.join(','), 'loading,running,passed,loading,running,passed') + }, + // Rendering a result is the page's own code, so it is a failure point of + // the page and not of the run: a renderer that throws must not cost the + // report every consumer is waiting for. + renderingThrows: async () => { + const { root, results } = page() + const append = results.append + const report = await startBrowserTestSources(root, ['a.mjs'], async () => { + // Break rendering only once the run is under way, so the page is + // built normally and only the per-result append fails. + Object.assign(results, { append: () => { throw new Error('render') } }) + return { proof: { t: () => undefined } } + }) + Object.assign(results, { append }) + assertEq(report.status, 'passed') + assertEq(report.totals.passed, 1) + }, // A root whose document has no window still runs and still answers: there // is simply nowhere to publish the promise or dispatch the event. withoutView: async () => { diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index 81daec8fd..3cc9c3781 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -159,7 +159,17 @@ workers, or visual regression testing. `playwright/test` and reuses the shared controller. - [ ] Run the same application in Chromium, Firefox, and WebKit. - [ ] Add the validation fixtures above; add CI only after proof bodies - demonstrably execute inside browsers. + demonstrably execute inside browsers. **That gate is now met** — the + unified runner was driven in Chromium over the generated page, 3435 proofs + linked and executed, so what still blocks a CI job is the controller + below, not evidence. Nothing in `.github/workflows/` starts a browser + today, and `npm run website` only *generates* the suite: it exits `0` with + a failing proof in the manifest, so the browser suite is not a gate + anywhere yet. +- [ ] Assert a floor on the number of proofs a run discovers. Nothing does + today, in any runner: a `collectTests` that silently skipped most leaves + would keep `fjs t` at exit `0`, and a suite that loses coverage cannot + report that it has. ### Related From 57e295bd75102a46f35e9ddeb268ef8bbeed2fc7 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:15:37 +0000 Subject: [PATCH 017/370] emergent_testing/browser: make the panic guard total, and stop diagnosing every infrastructure error as a load failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard added for a panicking proof tree was not itself total: describing the panic reads the value that caused it, so a proxy whose traps throw *itself* made `errorDetails` panic in turn and the page stuck in `running` again — the guard becoming the thing it was meant to prevent. It is the last handler there is, so it now says what it cannot describe rather than being thrown by it. `infrastructure-error` covers a run that panicked and a runner missing an operation as well as a module that would not link, so the summary no longer claims they all "failed to load" — a false diagnosis sends a reader to debug their imports. Each result still carries its own module and message. The browser clock reads `performance.timeOrigin + performance.now()` rather than `Date.now()`. The operation means the same thing — milliseconds since the epoch, as the Node runner answers — but a suite runs for minutes, and a report's duration is the difference between two reads: with wall-clock time an NTP correction inside a run makes that negative or inflated, which is what the deleted runner avoided by measuring in `performance.now()`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/module.mjs | 9 +++++- fjs/emergent_testing/browser/module.mjs | 28 ++++++++++++++-- fjs/emergent_testing/browser/proof.mjs | 43 +++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index d9ffbfbdd..871355e50 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -112,6 +112,13 @@ export const browserOperationMap = (run, importer = source => import(source)) => // before it ever starts loading — is a load failure like any other, so it // is caught here rather than escaping the effect it belongs to. import: path => io(async () => importer(path)), - now: async () => ok(Date.now()), + // `performance.timeOrigin + performance.now()`, not `Date.now()`. The + // operation means the same thing either way — milliseconds since the epoch, + // as the Node runner answers — but this one cannot go backwards. A suite + // runs for minutes, an NTP correction lands inside one, and the report's + // duration is the difference between two of these reads: with wall-clock + // time that difference can come out negative or inflated, which is what the + // deleted browser runner avoided by measuring in `performance.now()`. + now: async () => ok(performance.timeOrigin + performance.now()), sandbox: async f => ok(await sandbox(f)), }) diff --git a/fjs/emergent_testing/browser/module.mjs b/fjs/emergent_testing/browser/module.mjs index 3fd1b8ca5..a278bdfcd 100644 --- a/fjs/emergent_testing/browser/module.mjs +++ b/fjs/emergent_testing/browser/module.mjs @@ -35,11 +35,21 @@ import { browserOperationMap } from '../../effects/browser/module.mjs' import { errorDetails } from '../module.f.mjs' import { main, reportOf } from './module.f.mjs' import { ok } from '../../types/result/module.f.mjs' +import { tryCatch } from '../../types/result/module.mjs' /** @typedef {Window & { fjsBrowserTestReport?: Promise }} _TestWindow */ /** @typedef {(effect: Effect) => Promise>} _Run */ +/** + * What a run is reported as when even *describing* its panic panicked. + * + * There is nothing left to say about the value at that point — every way of + * reading it is a way of being thrown by it — so the report says exactly that + * rather than inventing a message. + */ +const unreadableFailure = 'The run failed with a value that cannot be read' + /** @type {(root: Element) => _TestWindow | null} */ const viewOf = root => root.ownerDocument.defaultView @@ -83,7 +93,11 @@ export const renderBrowserReport = (root, report) => { const summary = root.querySelector('[data-test-summary]') if (summary !== null) { summary.textContent = report.status === 'infrastructure-error' - ? `Infrastructure error: ${report.totals.failed} failed to load (${report.duration.toFixed(1)} ms)` + // Not "failed to load": this status also covers a run that panicked + // and a runner missing an operation, and naming the wrong cause + // sends a reader to debug their imports. Each result below carries + // its own module and message, so the detail is not lost. + ? `Infrastructure error: ${report.totals.failed} failed (${report.duration.toFixed(1)} ms)` : `${report.totals.passed} passed, ${report.totals.failed} failed (${report.duration.toFixed(1)} ms)` } const output = root.querySelector('[data-test-results]') @@ -162,7 +176,17 @@ export const startBrowserTestSources = (root, sources, importer = source => impo const settled = run(main({ browser, sources })).then( ([, value]) => value, error => { - const [message, stack] = errorDetails(error) + // Describing the panic reads the value that caused it, and the + // value is the reason there was one: a proxy whose traps throw + // *itself* makes `errorDetails` panic in turn. This is the last + // handler there is, so it is the one that may not fail — a second + // failure here is the page stuck in `running` again, with the + // guard that was supposed to prevent it. What it cannot describe, + // it says it cannot describe. + const described = tryCatch(() => errorDetails(error)) + const [message, stack] = described[0] === 'ok' + ? described[1] + : [unreadableFailure, ''] return reportOf('infrastructure-error', browser, 0, [ { module: '', path: '', status: 'failed', duration: 0, message, stack }]) }) diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 2d449aa46..0bdf102ce 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -191,7 +191,7 @@ export const proof = { assertEq(report.totals.failed, 1) assertEq(report.results[0]?.module, 'a') assertEq(report.results[0]?.message, '404') - assert(summary.textContent.startsWith('Infrastructure error: 1 failed to load'), summary.textContent) + assert(summary.textContent.startsWith('Infrastructure error: 1 failed'), summary.textContent) assertEq(states.join(','), 'loading,running,infrastructure-error') }, // The importer is page code, so obtaining the promise is itself a failure @@ -282,6 +282,39 @@ export const proof = { assertEq(report.status, 'passed') assertEq(report.totals.passed, 1) }, + // Describing a panic reads the value that caused it, so a value every trap + // of which throws *itself* makes the description panic in turn. That is the + // last handler there is: it may not fail, or the guard against a stuck page + // becomes the thing that sticks it. + unreadableFailure: async () => { + /** @type {ProxyHandler} */ + const handler = {} + const hostile = new Proxy({}, handler) + const rethrow = () => { throw hostile } + Object.assign(handler, { has: rethrow, get: rethrow, ownKeys: rethrow }) + const { root, states } = page() + const report = await startBrowserTestSources(root, ['a'], async () => ({ + proof: { boom: () => { throw hostile } }, + })) + assertEq(report.status, 'infrastructure-error') + assertEq(report.results[0]?.message, 'The run failed with a value that cannot be read') + assertEq(states.join(','), 'loading,running,infrastructure-error') + }, + // `infrastructure-error` covers a panic and a runner missing an operation as + // well as a module that would not link, so the summary must not diagnose + // every one of them as a loading failure. + infrastructureSummaryNamesNoCause: () => { + const { root, summary } = page() + renderBrowserReport(root, { + status: 'infrastructure-error', + browser: 'x', + totals: { tests: 1, passed: 0, failed: 1 }, + duration: 0, + results: [{ module: '', path: '', status: 'failed', duration: 0, message: 'no sandbox', stack: '' }], + }) + assert(!summary.textContent.includes('to load'), summary.textContent) + assert(summary.textContent.startsWith('Infrastructure error: 1 failed'), summary.textContent) + }, // A root whose document has no window still runs and still answers: there // is simply nowhere to publish the promise or dispatch the event. withoutView: async () => { @@ -330,8 +363,14 @@ export const proof = { awaitsPlainValue: async () => { assertEq(unwrap(await awaitOp(7))[0], 7) }, + // Epoch milliseconds, as the Node runner answers — but read through + // `performance`, so two reads never come out in the wrong order however + // the system clock is adjusted between them. now: async () => { - assert(unwrap(await now()) > 0) + const before = unwrap(await now()) + const after = unwrap(await now()) + assert(before > Date.UTC(2020, 0, 1), before) + assert(after >= before, [before, after]) }, sandboxMeasures: async () => { const { result, duration } = unwrap(await sandbox(() => 1)) From 68fc983723d7c7f0475b32b17c9723f19c9e8372 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:36:48 +0000 Subject: [PATCH 018/370] emergent_testing/todo: design the `catch` operation that guards the traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pressed on the one gap left, and correctly: since the two runners are now one, guarding the proof-tree traversal once covers `fjs t` and the browser together. The todo carried a vague "read user values behind `sandbox`" and one wrong reason for why that does not work. Timing is not the obstacle — the sub-tree walk runs after the leaf's promise has resolved, so a synchronous thunk over a settled value would be fine. The obstacle is the virtual runner: its `sandbox` is a deliberate pass-through, because a `.f.mjs` runner has no `try`/`catch` to implement a real one with, and routing the traversal through it would break every fixture. So the design is a second, honest operation beside it — `catch`, "run this pure thunk; a throw is the `error` branch" — with the file-by-file work and the proofs it restores written down. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/hostile-proof-values.md | 81 ++++++++++++++----- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/fjs/emergent_testing/todo/hostile-proof-values.md b/fjs/emergent_testing/todo/hostile-proof-values.md index faf815c1f..7b40e2591 100644 --- a/fjs/emergent_testing/todo/hostile-proof-values.md +++ b/fjs/emergent_testing/todo/hostile-proof-values.md @@ -34,27 +34,66 @@ is settable through `Symbol.toStringTag`, and an object carrying a `then` proof would then be assimilated, breaking the rule that only actual promises are asynchronous values. -### Preliminary design - -Both belong to the *operation*, not to the shared core, which is what makes one -fix serve every runner: - -- Reading a user value could move behind `sandbox`: the operation already runs - user code inside the host's `try`/`catch`, so it is the one place that can - enumerate a returned tree, or read a thrown value's `message`/`stack`, and - hand back something that is already ordinary data. The shared `errorDetails` - and `collectTests` would then read a record rather than an arbitrary value, - and stay total — which also lets the failure be reported against the leaf that - caused it instead of against the run. -- The brand check needs a test that a page cannot forge and that no proof tree - can pass by accident. Candidates: `Promise.resolve(p) === p` on the value's - own constructor, or asking each realm the runner knows about. Whatever is - chosen must be one function both interpreters call, or the two drift again. - -Neither is worth doing speculatively. Do the first when a real proof loses a -run to it, and the second when proofs genuinely execute in more than one realm — -which is the point [browser-testing](browser-testing.md) reaches with iframes or -workers. +### Design: a `catch` operation + +Reading a user value belongs to the *operation*, not to the shared core, which +is what makes one fix serve every runner. Since the two runners are now one, +guarding the traversal once covers `fjs t` and the browser together. + +**`sandbox` cannot hold it, and the reason is not the one it looks like.** +Timing is not the obstacle: the sub-tree walk in `runModule` happens *after* the +runner has resolved the leaf's promise, so `sandbox(() => collectTests(path, +false, r))` would run a pure synchronous thunk over an already-settled value. +The obstacle is the **virtual runner**. Its `sandbox` is a deliberate +pass-through — `f => state => [state, ok(f())]`, with the fixture returning the +`SandboxResult` it wants reported — because `../../effects/node/virtual` is +`.f.mjs` and FunctionalScript has no `try`/`catch` to implement a real one with. +Routing the traversal through `sandbox` would hand that handler a thunk +answering `_TestAndPath[]`, which it would cast to `SandboxResult` and every +fixture in `../proof.f.mjs` would break. + +So add a second, honest operation beside it: + +```ts +export type Catch = readonly['catch', (f: () => T) => OpResult>] +``` + +"Run this pure thunk; a throw is the `error` branch." It carries no clock and no +fixture convention, so each runner implements it truthfully: + +- `effects/node/module.mjs` and `effects/browser/module.mjs`: `tryCatch(f)`, one + line each, from `types/result/module.mjs`. +- `effects/node/virtual/module.f.mjs`: `ok(ok(f()))` — a pure runner still + cannot catch, and a hostile fixture still panics there, which is the same + bargain `sandbox` already makes. Virtual proofs use benign fixtures. + +`walk` then reads a sub-tree through `catch` and, on the `error` branch, reports +one failed result at that path instead of panicking — which is what restores +`exportedTreeThrows` / `returnedTreeThrows`, and gives `fjs t` a behaviour it +never had. `errorDetails` gets the same treatment at its one call site. + +The work is roughly: the operation and its constructor in `effects/common`, one +handler in each of the three runners, the `CommandSet` entries, the `walk` +change and its new result shape, and the mock maps in +`effects/common/proof.f.mjs` and `emergent_testing/browser/proof.f.mjs`. + +**The brand check** for cross-realm promises needs a test that a page cannot +forge and that no proof tree can pass by accident. Candidates: +`Promise.resolve(p) === p` on the value's own constructor, or asking each realm +the runner knows about. Whatever is chosen must be one function both +interpreters call, or the two drift again. Do it when proofs genuinely execute +in more than one realm — the point [browser-testing](browser-testing.md) reaches +with iframes or workers. + +### Tasks + +- [ ] Add the `catch` operation, its constructor, and a handler in each of the + Node, browser and virtual runners. +- [ ] Read sub-trees through it in `walk`, reporting an unreadable tree as one + failed result at its path rather than a panic. +- [ ] Restore `exportedTreeThrows` and `returnedTreeThrows`, and add the `fjs t` + counterparts the browser-only versions never had. +- [ ] Read a thrown value through it at `errorDetails`' call site. ### Constraints From 505766d8a6403d4e501232c46643df53decb1e7e Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:22:09 +0000 Subject: [PATCH 019/370] emergent_testing: one spelling for a test's name, and the three TODOs review asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page rendered `./a.proof.f.mjs .x` where the terminal rendered `import("./a.proof.f.mjs").proof.x()` — one identifier in two spellings, months after the semantics were shared, because rendering was still per host. The format now lives once, in `fmtCall`, which takes a module and an already-rendered key chain so a reporter holding a `TestResult` names a test exactly as `fjs t` does; `fmtImport` is that function over an unrendered `Path`. `passing` asserts the rendered line, so the two cannot drift again. The browser's `all` yields every ten effects rather than every twenty-five. A count measures the wrong thing — proofs differ in cost by orders of magnitude, so ten fast ones waste a boundary and one slow one stalls the page anyway — so this is a mitigation and is labelled as one, with the elapsed-time design in `todo/report-scheduling.md`. Three TODOs, none of them changes here: - `share-the-whole-runner.md` — the semantics are shared but the runner around them is written per host: discovery, reporting and the outcome. Compares an artificial effect per capability against injecting the host's verbs, with the formatting drift above as the symptom to keep in mind. - `report-scheduling.md` — yield on a time budget instead of a count. - `imports-promises-realms.md` — a study, not a design: a module namespace adopts a `then`, a proof tree refuses to, and `instanceof Promise` does not survive a realm. Three mechanisms whose interaction nobody has written down, which is why it keeps being rediscovered. `hostile-proof-values.md` hands the cross-realm brand check to the last of those rather than sketching it twice. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/module.mjs | 8 +- fjs/emergent_testing/browser/module.mjs | 6 +- fjs/emergent_testing/browser/proof.mjs | 6 ++ fjs/emergent_testing/module.f.mjs | 23 ++++- .../todo/hostile-proof-values.md | 13 ++- .../todo/imports-promises-realms.md | 73 ++++++++++++++++ .../todo/report-scheduling.md | 63 ++++++++++++++ .../todo/share-the-whole-runner.md | 84 +++++++++++++++++++ 8 files changed, 263 insertions(+), 13 deletions(-) create mode 100644 fjs/emergent_testing/todo/imports-promises-realms.md create mode 100644 fjs/emergent_testing/todo/report-scheduling.md create mode 100644 fjs/emergent_testing/todo/share-the-whole-runner.md diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index 871355e50..46a5df12c 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -58,8 +58,14 @@ import { toVec } from '../../types/uint8array/module.f.mjs' * * Yielding per effect would be the simpler rule and the wrong one: `setTimeout` * clamps to 4 ms once nested, which is minutes across a few thousand proofs. + * + * A count is the wrong measure and this number is a mitigation, not a design: + * proofs differ in cost by orders of magnitude, so a slice of ten fast ones + * yields immediately while a slice holding one slow one stalls the page for as + * long as that proof runs. Yielding on elapsed time instead is + * `fjs/emergent_testing/todo/report-scheduling.md`. */ -const batchSize = 25 +const batchSize = 10 /** @type {() => Promise} */ const macrotask = () => new Promise(resolve => { setTimeout(resolve, 0) }) diff --git a/fjs/emergent_testing/browser/module.mjs b/fjs/emergent_testing/browser/module.mjs index a278bdfcd..d64f162ac 100644 --- a/fjs/emergent_testing/browser/module.mjs +++ b/fjs/emergent_testing/browser/module.mjs @@ -32,7 +32,7 @@ import { asyncRun } from '../../effects/module.mjs' import { browserOperationMap } from '../../effects/browser/module.mjs' -import { errorDetails } from '../module.f.mjs' +import { errorDetails, fmtCall } from '../module.f.mjs' import { main, reportOf } from './module.f.mjs' import { ok } from '../../types/result/module.f.mjs' import { tryCatch } from '../../types/result/module.mjs' @@ -79,7 +79,9 @@ const renderResult = (document, result) => { const item = document.createElement('li') item.setAttribute('data-status', result.status) const detail = result.status === 'failed' ? `: ${result.message}\n${result.stack}` : '' - item.textContent = `${result.status === 'passed' ? 'PASS' : 'FAIL'} ${result.module} ${result.path} (${result.duration.toFixed(1)} ms)${detail}` + // `fmtCall`, so a test is named here exactly as `fjs t` names it — one + // identifier, one spelling, whichever runner is reporting. + item.textContent = `${result.status === 'passed' ? 'PASS' : 'FAIL'} ${fmtCall(result.module, result.path)} (${result.duration.toFixed(1)} ms)${detail}` return item } diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 0bdf102ce..e6d3633fb 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -136,6 +136,12 @@ export const proof = { assertEq(report.totals.tests, 1) assertEq(report.results[0]?.path, '.x') assertEq(statuses(results).join(','), 'passed') + // The page names a test exactly as `fjs t` names it. The two spellings + // had drifted — `./a .x` here against the call expression there — which + // is the thing a shared runner is supposed to make impossible. + assert( + results.children[0]?.textContent.startsWith('PASS import("a").proof.x()'), + results.children[0]?.textContent) assert(summary.textContent.startsWith('1 passed, 0 failed'), summary.textContent) assertEq(states.join(','), 'loading,running,passed') assertEq(view.events.length, 1) diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index 9c8d025c1..142f8ab12 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -330,14 +330,31 @@ export const fmtPath = path => path.reduce((/** @type {string} */ acc, k) => acc + fmtKey(k), '') /** - * Formats a fully-qualified test identifier as a JS-like expression, e.g. - * `import("./math.proof.f.ts").add()` or `import("./a.proof.f.ts").users[3].name()`. + * A fully-qualified test identifier, from a module and an **already-rendered** + * key chain: `import("./math.proof.f.mjs").proof.add()`. + * + * This is the one place the format lives, and it takes the rendered chain + * rather than a {@link Path} so that a reporter holding a {@link TestResult} — + * whose `path` is already a string — names a test exactly as `fjs t` does. It + * did not, and the browser page rendered `./math.proof.f.mjs .add` while the + * terminal rendered the call expression: one identifier in two spellings, which + * is the drift a shared runner is supposed to make impossible. + * + * @type {(file: string, path: string) => string} + */ +export const fmtCall = (file, path) => + `import(${JSON.stringify(file)}).proof${path}()` + +/** + * {@link fmtCall} over a {@link Path} that has not been rendered yet, e.g. + * `import("./math.proof.f.ts").proof.add()` or + * `import("./a.proof.f.ts").proof.users[3].name()`. * Self-contained per line — suitable for parallel output and as a CLI filter argument. * * @type {(file: string, path: Path) => string} */ export const fmtImport = (file, path) => - `import(${JSON.stringify(file)}).proof${fmtPath(path)}()` + fmtCall(file, fmtPath(path)) /** * Renders a key chain for terminal output: `| ` per level of depth, followed diff --git a/fjs/emergent_testing/todo/hostile-proof-values.md b/fjs/emergent_testing/todo/hostile-proof-values.md index 7b40e2591..1bda0e573 100644 --- a/fjs/emergent_testing/todo/hostile-proof-values.md +++ b/fjs/emergent_testing/todo/hostile-proof-values.md @@ -77,13 +77,10 @@ handler in each of the three runners, the `CommandSet` entries, the `walk` change and its new result shape, and the mock maps in `effects/common/proof.f.mjs` and `emergent_testing/browser/proof.f.mjs`. -**The brand check** for cross-realm promises needs a test that a page cannot -forge and that no proof tree can pass by accident. Candidates: -`Promise.resolve(p) === p` on the value's own constructor, or asking each realm -the runner knows about. Whatever is chosen must be one function both -interpreters call, or the two drift again. Do it when proofs genuinely execute -in more than one realm — the point [browser-testing](browser-testing.md) reaches -with iframes or workers. +**The brand check** for cross-realm promises is not designed here. It belongs +with the two mechanisms it keeps being confused with — a module namespace +adopting a `then`, and a proof tree refusing to — which are studied together in +[imports, promises and realms](imports-promises-realms.md). ### Tasks @@ -103,5 +100,7 @@ with iframes or workers. ### Related +- [Imports, promises and realms](imports-promises-realms.md) — where the + cross-realm brand check is studied. - [Browser testing](browser-testing.md) - [Test-runner behavior](661-test-runner-behavior.md) diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md new file mode 100644 index 000000000..10b24bf8b --- /dev/null +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -0,0 +1,73 @@ +## Investigate imports, promises and realms + +**Priority:** P3 +**Status:** open — investigation, not yet actionable + +### Problem + +Three mechanisms meet in the runner, none of them is written down as a rule, and +the code where they meet reads as a pile of special cases rather than a design. +They are separate mechanisms that happen to interact, and the interaction is +what nobody has stated: + +**A module namespace object is a thenable.** `import()` resolves by *adopting* +what a module exports, so a module exporting a function named `then` corrupts +its own dynamic import. That is why exporting `then` from a proof module is +forbidden ([`spec/todo/3240-export.md`](../../../spec/todo/3240-export.md)) — +but the rule lives in a spec issue and a README paragraph, and nothing checks +it. The proof discovery in `../../dev/module.f.mjs` imports whatever it finds. + +**A proof tree is not a thenable, even when it has a `then`.** The runner's rule +is that only an actual `Promise` is an asynchronous value, so `{ then: f }` +returned from a proof is a sub-tree with a test called `then` in it. This is the +opposite reading of the same property name, one layer down, and both readings +are correct in their own layer. Nothing says so in one place. + +**`instanceof Promise` is realm-local.** A promise built in an iframe, a worker +or a `node:vm` context is not `instanceof Promise` here, so it is walked as a +proof tree and a *rejected* one is reported as a pass. The deleted browser +runner defended against this with `Symbol.species` shadowing and an intrinsic +`then` — about 150 lines that were, fairly, called a magic mess; they were +removed when the runners were unified, on the grounds that `fjs t` never had +them. The defence is gone and the exposure is not. + +The three are usually discussed one at a time, which is why the interaction +keeps being rediscovered: the thing that makes a namespace dangerous (`then` is +adopted) is the thing the runner deliberately refuses to do (`then` is a name), +and the check that separates them (`instanceof`) is the one that does not +survive a realm boundary. + +### What to investigate + +This is a study, not a design. It is worth doing before +[browser-testing](browser-testing.md) puts proofs in iframes or workers, because +that is the point at which cross-realm promises stop being hypothetical. + +- **State the layering.** One document saying which layer adopts a `then` and + which layer refuses to, and why both are right. Until that exists, every fix + to one looks like a bug in the other. +- **Find a brand check that survives a realm and cannot be forged.** + `Object.prototype.toString` is forgeable through `Symbol.toStringTag`. + `Promise.resolve(p) === p` against the value's own constructor is a candidate. + Whatever is chosen must be one function every interpreter calls. +- **Decide whether the runner should see namespace objects at all.** If + discovery handed the runner a plain record of proofs rather than the module + namespace, the `then` export hazard would not reach it — and the `then`-export + ban could become a check rather than a convention. +- **Establish what the removed 150 lines actually bought**, from the proofs that + covered them (`species.proof.mjs` in this PR's history), so that whatever + replaces them is measured against the same cases rather than against a memory. + +### Constraints + +- An object carrying a `then` proof property must stay an ordinary proof tree. +- Whatever is added must apply to every runner. A defence in one host only is + what unifying the runners just finished removing. + +### Related + +- [Hostile proof values](hostile-proof-values.md) — the cross-realm promise + exposure, and the traversal guard it shares a cause with. +- [Browser testing](browser-testing.md) — iframes and workers. +- [`spec/todo/3240-export.md`](../../../spec/todo/3240-export.md) — the `then` + export ban. diff --git a/fjs/emergent_testing/todo/report-scheduling.md b/fjs/emergent_testing/todo/report-scheduling.md new file mode 100644 index 000000000..8176f8221 --- /dev/null +++ b/fjs/emergent_testing/todo/report-scheduling.md @@ -0,0 +1,63 @@ +## Yield on elapsed time, not on a count of proofs + +**Priority:** P3 +**Status:** open + +### Problem + +The browser runner's `all` starts its children in slices of ten and yields to +the event loop between slices +([`fjs/effects/browser/module.mjs`](../../effects/browser/module.mjs)). Ten is a +mitigation, not a design. + +A count measures the wrong thing. Proofs differ in cost by orders of magnitude — +most are microseconds, a few run for a second or more — so a slice of ten fast +proofs yields almost immediately and wastes a task boundary, while a slice +holding one slow proof stalls the page for as long as that proof runs and no +count would have helped. The page freezes in bursts, and the reported progress +stops with it, which is exactly when a reader most wants to see it move. The +number was 25 and is now 10 for that reason; the next person to notice a stall +will have the same argument for 5. + +### Preliminary design + +Yield on a **time budget** rather than a count: keep starting children while the +slice has spent less than some milliseconds — a frame's worth, or a small +multiple of one — and hand the loop back when it has. That bounds the *stall*, +which is the thing a reader actually experiences, and it self-tunes: a thousand +trivial proofs run in one slice and one slow proof yields after itself. + +The clock read has to be cheap and monotonic; `performance.now()` is both, and +the shared `sandbox` already measures each proof with it, so the elapsed time +may be available without a second read. + +Two questions to settle with measurement rather than by argument: + +- **Where the budget belongs.** In `all` alongside the slicing, or in the + reporting handler that renders? `all` is where the work is started, which is + what made the slicing correct in the first place. +- **Whether a slow proof can yield at all.** A single proof body is synchronous + from the runner's point of view; nothing can interrupt it. A budget bounds how + many *more* are started after one, not the stall the slow one itself causes. + Reporting the slow proof's *start* — not only its result — may matter more + than any scheduling change, and is the cheaper experiment. + +### Constraints + +- The Node runner has no frame to paint and must keep starting its children at + once; this is the browser interpreter's policy, as the slicing already is. +- `all` must keep answering every `Result` in the order its effects were given. + +### Tasks + +- [ ] Measure where the page actually stalls on the real suite, per slice, and + whether the cause is proof cost or rendering. +- [ ] Replace the count with an elapsed-time budget, and prove the boundary the + way `operations.allYieldsBetweenBatches` proves the current one. +- [ ] Consider reporting a proof's start as well as its result, so a stall is + visible rather than silent. + +### Related + +- [Browser testing](browser-testing.md) +- [Explicit browser test controls](browser-test-controls.md) diff --git a/fjs/emergent_testing/todo/share-the-whole-runner.md b/fjs/emergent_testing/todo/share-the-whole-runner.md new file mode 100644 index 000000000..485d4af92 --- /dev/null +++ b/fjs/emergent_testing/todo/share-the-whole-runner.md @@ -0,0 +1,84 @@ +## Share the whole runner, not just its proof semantics + +**Priority:** P2 +**Status:** open + +### Problem + +The proof *semantics* are shared: `../module.f.mjs` decides what a leaf is, how +a returned tree is walked, what `throw` means, which values are asynchronous, +how a path is spelled and how results are counted, and `fjs t` and the browser +both go through it. What is **not** shared is the runner around them. Each host +still writes its own program: + +| | `fjs t` | browser | +| --- | --- | --- | +| entry | `main` → `testAll` → `runModuleMap` | `browser/module.f.mjs`'s `main` | +| discovery | `loadModuleMap` over `readdir` + `import` | a generated manifest, linked one specifier at a time | +| reporting | `defaultReporter` → `Write` | `recordingReporter` → `report` | +| outcome | an exit code through `exitCodeStep` | a `BrowserTestReport` | + +Two of those four differ for a real reason and two do not. A browser has no +`stdout` and no exit code, so `Write` and `Program` genuinely cannot cross — +but "load these modules, run them, answer an outcome" is one program written +twice, and every future host writes it a third time. + +The formatting drift this issue was raised over is the symptom worth keeping in +mind: the page rendered `./a.proof.f.mjs .x` where the terminal rendered +`import("./a.proof.f.mjs").proof.x()`. One identifier, two spellings, months +after the semantics were shared — because *rendering* was still per host. That +is fixed (`fmtCall`), but only that instance of it. + +### Preliminary design + +Lift the host difference into the program's parameters instead of into a +separate program per host. Two shapes are worth comparing before either is +built: + +**An artificial effect per host capability.** Where a host lacks an operation, +replace it with one every host can implement at the semantic level: `log` is not +available in a browser, but `testReport` is — a page renders it, a terminal +formats it, an MCP server serializes it. `report`/`reported` already exist and +are exactly this move made once; the question is whether the *whole* set can be +expressed that way, including discovery and the run's outcome. + +**Dependency injection of the effect-producing functions.** The runner is +generic in its operation set and takes the host's verbs as a record: + +```ts +type Host = { + readonly log: (message: string) => Effect + readonly load: () => Effect + readonly import: (source: string) => Effect +} +``` + +`Reporter` is already this shape for one third of the job, so the question +is whether extending it beats adding operations, or whether the two are the +same thing written differently. + +Whichever is chosen, the test is concrete: adding a third host — an MCP server, +a worker, `fjs browser-test` — must not mean writing a fourth `main`. + +### Constraints + +- The shared semantics must not acquire terminal text or DOM: a `TestResult` + carries neither today and that is what lets both reporters render it. +- A browser must not gain a `Write` or a `Program` it cannot honour. Lifting the + abstraction means finding the operation both hosts *can* implement, not giving + one a stub. + +### Tasks + +- [ ] Inventory what each host's `main` does that is not host-specific. +- [ ] Choose between artificial effects and injected verbs, and write down why. +- [ ] Express discovery once, so a manifest and a `readdir` walk are two + implementations of one operation rather than two programs. +- [ ] Express the outcome once, so an exit code and a report are two renderings + of one value. + +### Related + +- [Browser testing](browser-testing.md) — the hosts that are still to come. +- [Test-runner behavior](661-test-runner-behavior.md) — the differences between + runners that are intentional, and must stay intentional. From 569636be1c6e58191ac732fddf00fdc69b3011d4 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:31:11 +0000 Subject: [PATCH 020/370] emergent_testing/todo: put the browser batching on probation rather than designing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batching is premature optimization. It was not added because anyone found the suite slow — `fjs t` schedules nothing at all and nobody has complained — but because a page that renders nothing until the run finishes looked wrong in review. Observing a behaviour is not the same as someone having a problem with it, and the count moved from 25 to 10 with no measurement on either side. So the scheduling todo now asks for the opposite of a design: remove the batching entirely, run the real suite in a browser, and look at what actually happens — the page may paint anyway, since module loading is network-bound and dominates the first seconds. Only if that produces a stall someone objects to is there a problem to solve, and the elapsed-time argument is kept for that case rather than presented as the plan. The code says the same thing where the constant is defined, so a reader meets the probation before the rationale. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/module.mjs | 12 ++- .../todo/report-scheduling.md | 96 ++++++++++--------- 2 files changed, 58 insertions(+), 50 deletions(-) diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index 46a5df12c..8b9224bf4 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -59,11 +59,13 @@ import { toVec } from '../../types/uint8array/module.f.mjs' * Yielding per effect would be the simpler rule and the wrong one: `setTimeout` * clamps to 4 ms once nested, which is minutes across a few thousand proofs. * - * A count is the wrong measure and this number is a mitigation, not a design: - * proofs differ in cost by orders of magnitude, so a slice of ten fast ones - * yields immediately while a slice holding one slow one stalls the page for as - * long as that proof runs. Yielding on elapsed time instead is - * `fjs/emergent_testing/todo/report-scheduling.md`. + * **This whole mechanism is on probation.** It was not added because anyone + * found the suite slow — `fjs t` schedules nothing at all and no one has + * complained — but because a page that renders nothing until the run finishes + * looked wrong. That is an observation, not a problem someone has, and the + * count has already moved from 25 to 10 with no measurement on either side. + * `fjs/emergent_testing/todo/report-scheduling.md` asks for the batching to be + * removed and the real suite watched before any of this is treated as a design. */ const batchSize = 10 diff --git a/fjs/emergent_testing/todo/report-scheduling.md b/fjs/emergent_testing/todo/report-scheduling.md index 8176f8221..69eddb14d 100644 --- a/fjs/emergent_testing/todo/report-scheduling.md +++ b/fjs/emergent_testing/todo/report-scheduling.md @@ -1,63 +1,69 @@ -## Yield on elapsed time, not on a count of proofs +## Try removing the browser runner's batching entirely **Priority:** P3 -**Status:** open +**Status:** open — experiment first, design only if the experiment says so ### Problem The browser runner's `all` starts its children in slices of ten and yields to the event loop between slices -([`fjs/effects/browser/module.mjs`](../../effects/browser/module.mjs)). Ten is a -mitigation, not a design. - -A count measures the wrong thing. Proofs differ in cost by orders of magnitude — -most are microseconds, a few run for a second or more — so a slice of ten fast -proofs yields almost immediately and wastes a task boundary, while a slice -holding one slow proof stalls the page for as long as that proof runs and no -count would have helped. The page freezes in bursts, and the reported progress -stops with it, which is exactly when a reader most wants to see it move. The -number was 25 and is now 10 for that reason; the next person to notice a stall -will have the same argument for 5. - -### Preliminary design - -Yield on a **time budget** rather than a count: keep starting children while the -slice has spent less than some milliseconds — a frame's worth, or a small -multiple of one — and hand the loop back when it has. That bounds the *stall*, -which is the thing a reader actually experiences, and it self-tunes: a thousand -trivial proofs run in one slice and one slow proof yields after itself. - -The clock read has to be cheap and monotonic; `performance.now()` is both, and -the shared `sandbox` already measures each proof with it, so the elapsed time -may be available without a second read. - -Two questions to settle with measurement rather than by argument: - -- **Where the budget belongs.** In `all` alongside the slicing, or in the - reporting handler that renders? `all` is where the work is started, which is - what made the slicing correct in the first place. -- **Whether a slow proof can yield at all.** A single proof body is synchronous - from the runner's point of view; nothing can interrupt it. A budget bounds how - many *more* are started after one, not the stall the slow one itself causes. - Reporting the slow proof's *start* — not only its result — may matter more - than any scheduling change, and is the cheaper experiment. +([`fjs/effects/browser/module.mjs`](../../effects/browser/module.mjs)). **That +batching is premature optimization and should probably not exist.** + +It was not added because anyone found the suite slow. It was added in a review +round, because without it the page renders nothing until the run finishes, and +that *looked* wrong. Observing a behaviour is not the same as someone having a +problem with it: nobody has reported a stall, and the number has already been +argued down from 25 to 10 with no measurement on either side of the change — +which is the shape of an optimization nobody can evaluate. + +`fjs t` is the reference and it schedules nothing at all. It starts every leaf +of a module at once, prints results as they land, and no one has complained. The +browser runner sharing its semantics but not its scheduling is a difference that +has to justify itself, and so far it has not. + +### The experiment, before any design + +Remove the batching completely — `all` back to `Promise.all` over every child, +no `macrotask`, no `batchSize` — and run the real suite in a browser. Then look: + +- Does the page actually stay blank until the end, or does the browser paint + anyway? Module loading is network-bound and dominates the first seconds, which + may be all the yielding a page needs. +- If it does stay blank, for how long, and does that matter to anyone reading a + passing run? A suite that finishes in two seconds with no intermediate frames + is not a problem; one that finishes in two minutes might be. +- Does anything except rendering depend on the yielding? + +Only if that produces a stall someone objects to is there a problem to solve, +and only then is the shape of a solution worth arguing about. If it comes to +that, the argument against a count still holds — proofs differ in cost by orders +of magnitude, so a slice of ten fast ones wastes a boundary while a slice +holding one slow one stalls anyway — and an elapsed-time budget bounds the thing +a reader actually experiences. Reporting a proof's *start* as well as its result +may serve better than any scheduling change, and is cheaper to try. ### Constraints -- The Node runner has no frame to paint and must keep starting its children at - once; this is the browser interpreter's policy, as the slicing already is. -- `all` must keep answering every `Result` in the order its effects were given. +- Whatever the answer, it is the browser interpreter's policy. The Node runner + has no frame to paint and must keep starting its children at once. +- `all` must keep answering every `Result` in the order its effects were given, + batching or not. +- `operations.allYieldsBetweenBatches` pins the current behaviour. Removing the + batching means removing that proof, not weakening it. ### Tasks -- [ ] Measure where the page actually stalls on the real suite, per slice, and - whether the cause is proof cost or rendering. -- [ ] Replace the count with an elapsed-time budget, and prove the boundary the - way `operations.allYieldsBetweenBatches` proves the current one. -- [ ] Consider reporting a proof's start as well as its result, so a stall is - visible rather than silent. +- [ ] Remove the batching and the yield; run the real suite in a browser and + write down what actually happens. +- [ ] Decide from that whether there is a problem at all. +- [ ] Only then, if there is: bound the stall by elapsed time rather than by a + count, and prove the boundary the way the current one is proved. ### Related - [Browser testing](browser-testing.md) - [Explicit browser test controls](browser-test-controls.md) +- [Share the whole runner](share-the-whole-runner.md) — the CLI runner's + scheduling is one more thing the two hosts do differently for no stated + reason. From 4923ddbf2b4e168264ee90c94e64d1588e8b6dfa Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:40:52 +0000 Subject: [PATCH 021/370] emergent_testing/todo: measure the browser scheduling instead of arguing about it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the real suite in headless Chromium four ways, 3435 proofs each, sampling how often the page visibly moves. Two findings, and the first contradicts what "remove the batching" expected. Removing the yield removes the progress: with plain `Promise.all` the first row appears at 39.8 s of a 39.7 s run — nothing at all until the end. A browser cannot paint between microtasks and every operation resolves through one, so the whole suite is a single task. It is not faster for it. The grouping was never the point; the clamp was. Yielding after every result — what a reader actually wants, and what `fjs t` does — costs 2% over no yielding at all when the yield is a `MessageChannel`. It cost 45% only through `setTimeout`, which clamps to 4 ms once nested: 3435 results times 4 ms is the whole difference. So batching was a workaround for a bad yield primitive, and the workaround is what made grouping look necessary. The todo carries the table and the change it indicates: delete `batchSize` and `runBatched`, yield after each result over an unclamped primitive, and re-point the proof at that. Not made here, per the request to keep it out of this PR. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/report-scheduling.md | 68 ++++++++++++------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/fjs/emergent_testing/todo/report-scheduling.md b/fjs/emergent_testing/todo/report-scheduling.md index 69eddb14d..ada9b0f21 100644 --- a/fjs/emergent_testing/todo/report-scheduling.md +++ b/fjs/emergent_testing/todo/report-scheduling.md @@ -1,7 +1,7 @@ ## Try removing the browser runner's batching entirely **Priority:** P3 -**Status:** open — experiment first, design only if the experiment says so +**Status:** open — the experiment is done; the change it indicates is not ### Problem @@ -22,26 +22,50 @@ of a module at once, prints results as they land, and no one has complained. The browser runner sharing its semantics but not its scheduling is a difference that has to justify itself, and so far it has not. -### The experiment, before any design +### The experiment, and what it measured -Remove the batching completely — `all` back to `Promise.all` over every child, -no `macrotask`, no `batchSize` — and run the real suite in a browser. Then look: +Run in headless Chromium against the generated page, 3435 proofs each time, +sampling the run's state every 150 ms. "Progress steps" counts how many distinct +result-counts a reader ever sees — how often the page visibly moves. -- Does the page actually stay blank until the end, or does the browser paint - anyway? Module loading is network-bound and dominates the first seconds, which - may be all the yielding a page needs. -- If it does stay blank, for how long, and does that matter to anyone reading a - passing run? A suite that finishes in two seconds with no intermediate frames - is not a problem; one that finishes in two minutes might be. -- Does anything except rendering depend on the yielding? +| `all` schedules | run | first row | progress steps | +| --- | ---: | ---: | ---: | +| no yielding at all (`Promise.all`) | 39.7 s | **39.8 s** | 2 | +| slices of 10, `setTimeout` yield | 40.2 s | 3.5 s | 49 | +| every result, `setTimeout` yield | **58.1 s** | 3.5 s | 229 | +| **every result, `MessageChannel` yield** | **40.4 s** | 3.6 s | 123 | -Only if that produces a stall someone objects to is there a problem to solve, -and only then is the shape of a solution worth arguing about. If it comes to -that, the argument against a count still holds — proofs differ in cost by orders -of magnitude, so a slice of ten fast ones wastes a boundary while a slice -holding one slow one stalls anyway — and an elapsed-time budget bounds the thing -a reader actually experiences. Reporting a proof's *start* as well as its result -may serve better than any scheduling change, and is cheaper to try. +Two things fall out, and the first is not what "remove the batching" expected: + +**Removing the yield removes the progress.** With no yielding the page shows +nothing at all until the run ends — first row at 39.8 s of a 39.7 s run. It is +not that the browser paints anyway; every operation resolves through a microtask +and a browser cannot paint between microtasks, so the whole suite is one task. +The run is not faster for it either (39.7 s against 40.2 s). + +**The grouping was never the point — the clamp was.** Yielding after *every* +result is what a reader actually wants, and it costs nothing: 40.4 s against +39.7 s with no yielding, about 2%. It cost 45% only through `setTimeout`, which +clamps to 4 ms once nested — 3435 results × 4 ms is the entire difference. +`MessageChannel` (or `scheduler.yield()` where available) has no clamp. + +So batching was the wrong mechanism, as suspected, but not because scheduling is +unnecessary: it was a workaround for a bad yield primitive, and the workaround is +what made grouping look necessary. `fjs t` prints each result as it lands and +that is the behaviour to match — one result, one update — which per-result +yielding gives and grouping only approximates. + +### Tasks + +- [x] Remove the batching and the yield; run the real suite in a browser and + write down what actually happens. — table above. +- [ ] Replace `batchSize`/`runBatched` with a yield after every result, over a + yield primitive with no clamp; delete the batch-size constant entirely. +- [ ] Re-point `operations.allYieldsBetweenBatches` at the new behaviour: it + pins that a boundary exists between children, and should pin that one + exists after *each* child. +- [ ] Check the yield primitive across browsers, and whether `scheduler.yield()` + is worth preferring where it exists. ### Constraints @@ -52,14 +76,6 @@ may serve better than any scheduling change, and is cheaper to try. - `operations.allYieldsBetweenBatches` pins the current behaviour. Removing the batching means removing that proof, not weakening it. -### Tasks - -- [ ] Remove the batching and the yield; run the real suite in a browser and - write down what actually happens. -- [ ] Decide from that whether there is a problem at all. -- [ ] Only then, if there is: bound the stall by elapsed time rather than by a - count, and prove the boundary the way the current one is proved. - ### Related - [Browser testing](browser-testing.md) From cbc445420466cc072551773593f5158cd87b15a6 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:48:52 +0000 Subject: [PATCH 022/370] effects/browser: `all` must start every child before it awaits any MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slice loop awaited each slice before starting the next, which broke `all`'s concurrency promise rather than delaying it: a child waiting on something a later sibling produces waited for a sibling that was never started, and the run hung with no report and no completion event — on a graph the Node runner completes. Reproduced with a proof that waits on a gate its eleventh sibling opens; it hangs before the fix and passes after. The loop now starts every effect, yielding between one slice's launch and the next, and awaits them all at the end. That keeps the paint boundary — what a slice does when it starts is exactly the work worth bounding, since a proof body runs synchronously inside `sandbox` before that handler's first `await` — while restoring the concurrency. `allStartsEveryChildBeforeAwaiting` pins it. It also corrects the scheduling todo, whose measurements were taken against the serializing loop. "Yield after every result" was the wrong way to describe the target: per-result yielding *is* sequential execution, a bigger break than the batching it was meant to remove. Per-*launch* yielding is the right shape, costs about 3% over no yielding at all, and reaches the first row sooner. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/module.mjs | 24 ++++++++---- fjs/emergent_testing/browser/proof.mjs | 25 +++++++++++++ .../todo/report-scheduling.md | 37 ++++++++++++++----- 3 files changed, 69 insertions(+), 17 deletions(-) diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index 8b9224bf4..8e43d0f51 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -73,8 +73,19 @@ const batchSize = 10 const macrotask = () => new Promise(resolve => { setTimeout(resolve, 0) }) /** - * Runs `effects` in slices of {@link batchSize}, yielding to the event loop - * between them, and answers every `Result` in the order the effects were given. + * Starts `effects` in slices of {@link batchSize}, yielding to the event loop + * between one slice's *launch* and the next, and answers every `Result` in the + * order the effects were given. + * + * **Every effect is started before any is awaited**, which is not a detail. + * `all` promises its children run concurrently, and a runner that awaited each + * slice before starting the next would break that promise rather than merely + * delay it: a child waiting on something a later sibling produces would wait + * for a sibling that is never started, and the run would hang with no report — + * on a graph the Node runner completes. Yielding between launches costs + * nothing, because what a slice does when it starts is exactly the work worth + * bounding: a proof body runs synchronously inside `sandbox` before that + * handler's first `await`. * * @template T * @template E @@ -83,16 +94,15 @@ const macrotask = () => new Promise(resolve => { setTimeout(resolve, 0) }) * @returns {Promise[]>} */ const runBatched = async (run, effects) => { - /** @type {readonly Result[]} */ - let done = [] + /** @type {readonly Promise>[]} */ + let started = [] let index = 0 while (index < effects.length) { - const batch = await Promise.all(effects.slice(index, index + batchSize).map(e => run(e))) - done = [...done, ...batch] + started = [...started, ...effects.slice(index, index + batchSize).map(e => run(e))] index += batchSize if (index < effects.length) { await macrotask() } } - return done + return Promise.all(started) } /** diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index e6d3633fb..91be4fb49 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -9,6 +9,7 @@ * shared core and its own proofs; what is checked here is that a browser run * reaches it, renders it, and publishes it. * + * @import { Result } from '../../types/result/types.ts' * @import { Module } from '../../effects/common/types.ts' * @import { CommonRun } from '../../effects/browser/module.mjs' * @import { BrowserTestReport } from './types.ts' @@ -18,6 +19,7 @@ import { assert, assertEq, assertNotNullish } from '../../asserts/module.f.mjs' import { browserOperationMap } from '../../effects/browser/module.mjs' import { asyncRun } from '../../effects/module.mjs' import { pureOk } from '../../effects/module.f.mjs' +import { all as allEffect, sandbox as sandboxEffect } from '../../effects/common/module.f.mjs' import { renderBrowserReport, startBrowserTestSources } from './module.mjs' import { unwrap } from '../../types/result/module.f.mjs' @@ -387,6 +389,29 @@ export const proof = { const results = unwrap(await all(pureOk(1), pureOk(2))) assertEq(results.map(unwrap).join(','), '1,2') }, + // Slicing must not serialize: every child is *started* before any is + // awaited, so a child waiting on something a later sibling produces + // still sees that sibling run. Awaiting each slice before starting the + // next hangs this — the releaser sits in the second slice, which is + // never reached — on a graph the Node runner completes. + allStartsEveryChildBeforeAwaiting: async () => { + /** @type {(value: unknown) => void} */ + let release = () => undefined + /** @type {Promise} */ + const gate = new Promise(resolve => { release = resolve }) + const filler = sandboxEffect(() => 0) + const waits = sandboxEffect(() => gate) + const releases = sandboxEffect(() => { release(1); return 0 }) + const many = [waits, ...[...new Array(9).keys()].map(() => filler), releases] + /** @type {'hung'} */ + const hung = 'hung' + const outcome = await Promise.race([ + commonRun(allEffect(...many)), + new Promise(resolve => { setTimeout(resolve, 1000, hung) }), + ]) + assert(outcome !== hung, 'all serialized its slices') + assertEq(unwrap(/** @type {Result} */ (outcome)).length, 11) + }, // Past the batch size `all` hands the event loop back, which is the only // thing that lets a page paint mid-suite: a timer queued before the call // has to run before it resolves. Without the slicing every child settles diff --git a/fjs/emergent_testing/todo/report-scheduling.md b/fjs/emergent_testing/todo/report-scheduling.md index ada9b0f21..183514a63 100644 --- a/fjs/emergent_testing/todo/report-scheduling.md +++ b/fjs/emergent_testing/todo/report-scheduling.md @@ -31,9 +31,12 @@ result-counts a reader ever sees — how often the page visibly moves. | `all` schedules | run | first row | progress steps | | --- | ---: | ---: | ---: | | no yielding at all (`Promise.all`) | 39.7 s | **39.8 s** | 2 | -| slices of 10, `setTimeout` yield | 40.2 s | 3.5 s | 49 | -| every result, `setTimeout` yield | **58.1 s** | 3.5 s | 229 | -| **every result, `MessageChannel` yield** | **40.4 s** | 3.6 s | 123 | +| slices of 10, `setTimeout` yield | 39.8 s | 4.0 s | 29 | +| every launch, `setTimeout` yield | **58.1 s** | 3.5 s | 229 | +| **every launch, `MessageChannel` yield** | **41.1 s** | 3.1 s | 91 | + +(Sampled at 150 ms, so "progress steps" is a floor and varies a little run to +run; the shape is what matters, not the digits.) Two things fall out, and the first is not what "remove the batching" expected: @@ -44,10 +47,22 @@ and a browser cannot paint between microtasks, so the whole suite is one task. The run is not faster for it either (39.7 s against 40.2 s). **The grouping was never the point — the clamp was.** Yielding after *every* -result is what a reader actually wants, and it costs nothing: 40.4 s against -39.7 s with no yielding, about 2%. It cost 45% only through `setTimeout`, which -clamps to 4 ms once nested — 3435 results × 4 ms is the entire difference. -`MessageChannel` (or `scheduler.yield()` where available) has no clamp. +launch is what a reader actually wants, and it costs about 3%: 41.1 s against +39.7 s with no yielding, and it reaches the first row sooner. It cost 45% only +through `setTimeout`, which clamps to 4 ms once nested — 3435 × 4 ms is the +entire difference. `MessageChannel` (or `scheduler.yield()` where available) has +no clamp. + +**A yield between *launches*, never between a launch and its result.** `all` +promises its children run concurrently, and a slice loop that awaited each slice +before starting the next broke that promise rather than delaying it: a child +waiting on something a later sibling produces waited for a sibling that was +never started, and the run hung with no report — on a graph the Node runner +completes. That is fixed and pinned by +`operations.allStartsEveryChildBeforeAwaiting`; the numbers above are from the +fixed loop. It is also why "yield after every result" is the wrong way to say +this: per-*result* yielding is sequential execution, which is a bigger break +than the batching it was meant to remove. So batching was the wrong mechanism, as suspected, but not because scheduling is unnecessary: it was a workaround for a bad yield primitive, and the workaround is @@ -59,11 +74,13 @@ yielding gives and grouping only approximates. - [x] Remove the batching and the yield; run the real suite in a browser and write down what actually happens. — table above. -- [ ] Replace `batchSize`/`runBatched` with a yield after every result, over a - yield primitive with no clamp; delete the batch-size constant entirely. +- [ ] Replace `batchSize` with a yield after every *launch*, over a yield + primitive with no clamp; delete the batch-size constant entirely. Keep + the launch-then-await shape — the concurrency, not just the boundary. - [ ] Re-point `operations.allYieldsBetweenBatches` at the new behaviour: it pins that a boundary exists between children, and should pin that one - exists after *each* child. + exists after *each* child. `allStartsEveryChildBeforeAwaiting` stays as + it is — it pins the concurrency, which no scheduling change may cost. - [ ] Check the yield primitive across browsers, and whether `scheduler.yield()` is worth preferring where it exists. From 07736137cdc66c07bb5f29d1837c8692859fc431 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:50:51 +0000 Subject: [PATCH 023/370] emergent_testing/todo: record the lost loader stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linking is an `Import` effect now and its failure is an `IoError`, which is `{ code?, message }` — so a module-loading failure reports `stack: ''` where the deleted runner reported the loader's own frames. Those frames are what name the importing module and line for a broken dependency graph, and this section already requires failures to retain a stack, so it is a regression rather than a declared trade-off. The task carries the fix: one additive optional field on `IoErrorInfo`. The rationale that made `IoError` drop it — a stack does not survive a wire hop — is right about `cause` and own properties and wrong about a stack, which is a string. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/emergent_testing/todo/browser-testing.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index 3cc9c3781..6c32a7f47 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -166,6 +166,20 @@ workers, or visual regression testing. today, and `npm run website` only *generates* the suite: it exits `0` with a failing proof in the manifest, so the browser suite is not a gate anywhere yet. +- [ ] Keep a module-loading failure's stack. This section requires failures to + retain "module path, test path, message, and stack", and a *proof* failure + does — but a **load** failure no longer does. Linking is an `Import` + effect now, and its failure is an `IoError`, which is `{ code?, message }`: + `toIoError` drops the stack, so the report shows `stack: ''` where the + deleted runner showed the loader's own frames, which are what name the + importing module and line for a broken graph. The fix is one additive + optional field, `stack?: string` on `IoErrorInfo` in + `../../effects/common/types.ts`, filled by `toIoError` and read by + `infrastructureResult`. `IoError`'s rationale for dropping it — "a stack, a + `cause`, and arbitrary own properties do not survive a wire hop" — is right + about the last two and wrong about a stack, which is a string. Note that + reading `.stack` is a user-observable operation on a hostile value, the + same exposure `toIoError` already has reading `.message`. - [ ] Assert a floor on the number of proofs a run discovers. Nothing does today, in any runner: a `collectTests` that silently skipped most leaves would keep `fjs t` at exit `0`, and a suite that loses coverage cannot From 401f1917c33947da06b73994bac0bc4cd8863269 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 10:02:41 +0000 Subject: [PATCH 024/370] effects/browser: delete the batch size; yield after every launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You checked `batchSize = 1` and found it much more responsive, at almost twice the run time. It does not have to cost that: the 2x was `setTimeout`'s 4 ms clamp, not the yielding. Over a `MessageChannel` the same per-launch yield costs about 3%. So there is no batch size any more. `all` starts every effect, hands the event loop back between one launch and the next, and awaits them all at the end. Measured on the real suite in Chromium: 40.7 s against 39.7 s with no yielding at all, first row at 2.7 s (against 4.0 s at slices of ten), and roughly three times as many visible progress steps. No constant left to tune, which was the complaint. `todo/report-scheduling.md` is deleted — its experiment is done and its answer is the code above. The one part that outlives it, checking the yield primitive across browsers and whether `scheduler.yield()` is worth preferring, moves to the cross-browser task in `browser-testing.md`. `allStartsEveryChildBeforeAwaiting` is rewritten. It raced a 1 s wall clock, which in a suite of 3499 concurrent proofs measures how loaded the machine is — it failed once under coverage. It now counts turns of the event loop and records which opener reached the gate first, so it fails rather than hangs, and cannot flake. Checked in both directions against a serializing `all`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/module.mjs | 85 +++++++-------- fjs/emergent_testing/browser/proof.mjs | 52 ++++++--- fjs/emergent_testing/todo/browser-testing.md | 5 +- .../todo/report-scheduling.md | 102 ------------------ 4 files changed, 82 insertions(+), 162 deletions(-) delete mode 100644 fjs/emergent_testing/todo/report-scheduling.md diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index 8e43d0f51..799da2792 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -42,50 +42,49 @@ import { toVec } from '../../types/uint8array/module.f.mjs' */ /** - * How many effects one `all` starts before it hands the event loop back. + * Hands the event loop back, so the browser gets a turn. * - * **A browser needs a real task boundary to paint, and only `all` can give it - * one.** Every operation resolves through a microtask, so a page running a - * suite of any size would show its first frame until the last proof body had - * finished — `all` starts every child in the same turn, and a child that yielded - * inside its own continuation would pause only itself while its siblings ran on. - * Slicing the children is what bounds the work between two frames. - * - * `all` promises that its effects run concurrently and that it answers each - * one's whole `Result`. Neither says they start simultaneously, so the slicing - * is the runner's business — the Node runner has no frame to paint and starts - * them all at once. + * **Not `setTimeout`.** It clamps to 4 ms once nested, and a yield between every + * launch across a few thousand proofs is then minutes of pure clamp — measured + * at 58 s against 40 s on the real suite. That cost is what once made grouping + * the launches look necessary. A `MessageChannel` message is an ordinary task + * with no clamp, so the same per-launch yield costs about 3%. * - * Yielding per effect would be the simpler rule and the wrong one: `setTimeout` - * clamps to 4 ms once nested, which is minutes across a few thousand proofs. - * - * **This whole mechanism is on probation.** It was not added because anyone - * found the suite slow — `fjs t` schedules nothing at all and no one has - * complained — but because a page that renders nothing until the run finishes - * looked wrong. That is an observation, not a problem someone has, and the - * count has already moved from 25 to 10 with no measurement on either side. - * `fjs/emergent_testing/todo/report-scheduling.md` asks for the batching to be - * removed and the real suite watched before any of this is treated as a design. + * @type {() => Promise} */ -const batchSize = 10 - -/** @type {() => Promise} */ -const macrotask = () => new Promise(resolve => { setTimeout(resolve, 0) }) +const yieldToLoop = () => new Promise(resolve => { + const { port1, port2 } = new MessageChannel() + port1.onmessage = () => { port1.close(); resolve(undefined) } + port2.postMessage(0) +}) /** - * Starts `effects` in slices of {@link batchSize}, yielding to the event loop - * between one slice's *launch* and the next, and answers every `Result` in the - * order the effects were given. + * Starts every effect, handing the event loop back between one launch and the + * next, and answers each `Result` in the order the effects were given. + * + * **A browser needs a real task boundary to paint, and only `all` can give it + * one.** Every operation resolves through a microtask, and a browser cannot + * paint between microtasks, so without this the whole suite is a single task: + * measured on the real suite, the first result appears at 39.8 s of a 39.7 s + * run — nothing at all until the end, and no faster for it. What a launch does + * is exactly the work worth bounding, because a proof body runs synchronously + * inside `sandbox` before that handler's first `await`. * * **Every effect is started before any is awaited**, which is not a detail. - * `all` promises its children run concurrently, and a runner that awaited each - * slice before starting the next would break that promise rather than merely - * delay it: a child waiting on something a later sibling produces would wait - * for a sibling that is never started, and the run would hang with no report — - * on a graph the Node runner completes. Yielding between launches costs - * nothing, because what a slice does when it starts is exactly the work worth - * bounding: a proof body runs synchronously inside `sandbox` before that - * handler's first `await`. + * `all` promises its children run concurrently, and awaiting one before + * starting the next would break that promise rather than delay it: a child + * waiting on something a later sibling produces would wait for a sibling that + * is never started, and the run would hang with no report — on a graph the Node + * runner completes. `all` says its children run concurrently and that it + * answers every `Result`; it does not say they start in the same task, which is + * what leaves the scheduling to the runner. The Node runner has no frame to + * paint and starts them all at once. + * + * There is deliberately **no batch size**. Grouping launches was a workaround + * for `setTimeout`'s clamp, and a count is the wrong measure anyway — proofs + * differ in cost by orders of magnitude, so a group of ten fast ones wastes a + * boundary while a group holding one slow one stalls regardless. With an + * unclamped yield there is no constant left to tune. * * @template T * @template E @@ -93,14 +92,12 @@ const macrotask = () => new Promise(resolve => { setTimeout(resolve, 0) }) * @param {readonly Effect[]} effects * @returns {Promise[]>} */ -const runBatched = async (run, effects) => { +const runYielding = async (run, effects) => { /** @type {readonly Promise>[]} */ let started = [] - let index = 0 - while (index < effects.length) { - started = [...started, ...effects.slice(index, index + batchSize).map(e => run(e))] - index += batchSize - if (index < effects.length) { await macrotask() } + for (const effect of effects) { + if (started.length !== 0) { await yieldToLoop() } + started = [...started, run(effect)] } return Promise.all(started) } @@ -117,7 +114,7 @@ const runBatched = async (run, effects) => { * @type {(run: CommonRun, importer?: BrowserImporter) => ToAsyncOperationMap} */ export const browserOperationMap = (run, importer = source => import(source)) => ({ - all: async (...effects) => ok(await runBatched(run, effects)), + all: async (...effects) => ok(await runYielding(run, effects)), await: async p => ok(await awaitPromise(p)), fetch: url => io(async () => { const response = await globalThis.fetch(url) diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 91be4fb49..004af30b1 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -213,9 +213,8 @@ export const proof = { assertEq(report.status, 'infrastructure-error') assertEq(report.results[0]?.message, 'bad specifier') }, - // Past the batch size the adapter hands the event loop back, so a long - // suite paints instead of freezing the page on its first frame. - batches: async () => { + // A long suite runs to completion with a yield between every launch. + manyLeaves: async () => { const proof = Object.fromEntries( [...new Array(60).keys()].map(i => [`t${i}`, () => undefined])) const report = await run(proof) @@ -395,29 +394,52 @@ export const proof = { // next hangs this — the releaser sits in the second slice, which is // never reached — on a graph the Node runner completes. allStartsEveryChildBeforeAwaiting: async () => { + // The gate is opened either by the eleventh child — which is the + // property under test — or, after far more turns of the event loop + // than every launch can need, by the fallback below. Which one + // opened it is the assertion. + // + // Counting turns rather than milliseconds is deliberate: this proof + // runs concurrently with the rest of the suite, so a wall-clock + // deadline measures how loaded the machine is, not what `all` did. + // The fallback exists so a serializing `all` *fails* here instead of + // hanging the run. + /** @type {string | null} */ + let openedBy = null /** @type {(value: unknown) => void} */ let release = () => undefined /** @type {Promise} */ const gate = new Promise(resolve => { release = resolve }) + // Whoever opens the gate *first* is recorded. A later opener must + // not overwrite it: a serializing `all` still reaches the sibling + // eventually, just far too late to have been what unblocked the + // first child. + /** @type {(who: string) => void} */ + const open = who => { + if (openedBy === null) { openedBy = who } + release(0) + } + const fallback = async () => { + for (let turn = 0; turn < 50 && openedBy === null; turn += 1) { + await new Promise(resolve => { setTimeout(resolve, 0) }) + } + open('the fallback') + } + void fallback() const filler = sandboxEffect(() => 0) const waits = sandboxEffect(() => gate) - const releases = sandboxEffect(() => { release(1); return 0 }) + const releases = sandboxEffect(() => { open('a later sibling'); return 0 }) const many = [waits, ...[...new Array(9).keys()].map(() => filler), releases] - /** @type {'hung'} */ - const hung = 'hung' - const outcome = await Promise.race([ - commonRun(allEffect(...many)), - new Promise(resolve => { setTimeout(resolve, 1000, hung) }), - ]) - assert(outcome !== hung, 'all serialized its slices') - assertEq(unwrap(/** @type {Result} */ (outcome)).length, 11) + const results = unwrap(await commonRun(allEffect(...many))) + assertEq(openedBy, 'a later sibling') + assertEq(results.length, 11) }, - // Past the batch size `all` hands the event loop back, which is the only + // `all` hands the event loop back between launches, which is the only // thing that lets a page paint mid-suite: a timer queued before the call - // has to run before it resolves. Without the slicing every child settles + // has to run before it resolves. Without the yield every child settles // on microtasks and no timer gets a turn — which is what this asserts, // since the effects below perform nothing. - allYieldsBetweenBatches: async () => { + allYieldsBetweenLaunches: async () => { let fired = false setTimeout(() => { fired = true }, 0) const many = [...new Array(60).keys()].map(i => pureOk(i)) diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index 6c32a7f47..0e6eeece8 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -157,7 +157,10 @@ workers, or visual regression testing. - [ ] Implement `fjs browser-test` without any Playwright dependency. - [ ] Implement a Playwright Test adapter that dynamically resolves external `playwright/test` and reuses the shared controller. -- [ ] Run the same application in Chromium, Firefox, and WebKit. +- [ ] Run the same application in Chromium, Firefox, and WebKit. Check the + yield `all` uses to give the page a turn while it runs + (`MessageChannel`, `../../effects/browser/module.mjs`) behaves in each, + and whether `scheduler.yield()` is worth preferring where it exists. - [ ] Add the validation fixtures above; add CI only after proof bodies demonstrably execute inside browsers. **That gate is now met** — the unified runner was driven in Chromium over the generated page, 3435 proofs diff --git a/fjs/emergent_testing/todo/report-scheduling.md b/fjs/emergent_testing/todo/report-scheduling.md deleted file mode 100644 index 183514a63..000000000 --- a/fjs/emergent_testing/todo/report-scheduling.md +++ /dev/null @@ -1,102 +0,0 @@ -## Try removing the browser runner's batching entirely - -**Priority:** P3 -**Status:** open — the experiment is done; the change it indicates is not - -### Problem - -The browser runner's `all` starts its children in slices of ten and yields to -the event loop between slices -([`fjs/effects/browser/module.mjs`](../../effects/browser/module.mjs)). **That -batching is premature optimization and should probably not exist.** - -It was not added because anyone found the suite slow. It was added in a review -round, because without it the page renders nothing until the run finishes, and -that *looked* wrong. Observing a behaviour is not the same as someone having a -problem with it: nobody has reported a stall, and the number has already been -argued down from 25 to 10 with no measurement on either side of the change — -which is the shape of an optimization nobody can evaluate. - -`fjs t` is the reference and it schedules nothing at all. It starts every leaf -of a module at once, prints results as they land, and no one has complained. The -browser runner sharing its semantics but not its scheduling is a difference that -has to justify itself, and so far it has not. - -### The experiment, and what it measured - -Run in headless Chromium against the generated page, 3435 proofs each time, -sampling the run's state every 150 ms. "Progress steps" counts how many distinct -result-counts a reader ever sees — how often the page visibly moves. - -| `all` schedules | run | first row | progress steps | -| --- | ---: | ---: | ---: | -| no yielding at all (`Promise.all`) | 39.7 s | **39.8 s** | 2 | -| slices of 10, `setTimeout` yield | 39.8 s | 4.0 s | 29 | -| every launch, `setTimeout` yield | **58.1 s** | 3.5 s | 229 | -| **every launch, `MessageChannel` yield** | **41.1 s** | 3.1 s | 91 | - -(Sampled at 150 ms, so "progress steps" is a floor and varies a little run to -run; the shape is what matters, not the digits.) - -Two things fall out, and the first is not what "remove the batching" expected: - -**Removing the yield removes the progress.** With no yielding the page shows -nothing at all until the run ends — first row at 39.8 s of a 39.7 s run. It is -not that the browser paints anyway; every operation resolves through a microtask -and a browser cannot paint between microtasks, so the whole suite is one task. -The run is not faster for it either (39.7 s against 40.2 s). - -**The grouping was never the point — the clamp was.** Yielding after *every* -launch is what a reader actually wants, and it costs about 3%: 41.1 s against -39.7 s with no yielding, and it reaches the first row sooner. It cost 45% only -through `setTimeout`, which clamps to 4 ms once nested — 3435 × 4 ms is the -entire difference. `MessageChannel` (or `scheduler.yield()` where available) has -no clamp. - -**A yield between *launches*, never between a launch and its result.** `all` -promises its children run concurrently, and a slice loop that awaited each slice -before starting the next broke that promise rather than delaying it: a child -waiting on something a later sibling produces waited for a sibling that was -never started, and the run hung with no report — on a graph the Node runner -completes. That is fixed and pinned by -`operations.allStartsEveryChildBeforeAwaiting`; the numbers above are from the -fixed loop. It is also why "yield after every result" is the wrong way to say -this: per-*result* yielding is sequential execution, which is a bigger break -than the batching it was meant to remove. - -So batching was the wrong mechanism, as suspected, but not because scheduling is -unnecessary: it was a workaround for a bad yield primitive, and the workaround is -what made grouping look necessary. `fjs t` prints each result as it lands and -that is the behaviour to match — one result, one update — which per-result -yielding gives and grouping only approximates. - -### Tasks - -- [x] Remove the batching and the yield; run the real suite in a browser and - write down what actually happens. — table above. -- [ ] Replace `batchSize` with a yield after every *launch*, over a yield - primitive with no clamp; delete the batch-size constant entirely. Keep - the launch-then-await shape — the concurrency, not just the boundary. -- [ ] Re-point `operations.allYieldsBetweenBatches` at the new behaviour: it - pins that a boundary exists between children, and should pin that one - exists after *each* child. `allStartsEveryChildBeforeAwaiting` stays as - it is — it pins the concurrency, which no scheduling change may cost. -- [ ] Check the yield primitive across browsers, and whether `scheduler.yield()` - is worth preferring where it exists. - -### Constraints - -- Whatever the answer, it is the browser interpreter's policy. The Node runner - has no frame to paint and must keep starting its children at once. -- `all` must keep answering every `Result` in the order its effects were given, - batching or not. -- `operations.allYieldsBetweenBatches` pins the current behaviour. Removing the - batching means removing that proof, not weakening it. - -### Related - -- [Browser testing](browser-testing.md) -- [Explicit browser test controls](browser-test-controls.md) -- [Share the whole runner](share-the-whole-runner.md) — the CLI runner's - scheduling is one more thing the two hosts do differently for no stated - reason. From 5a74d4d654a6c947d6dd7a7a3dae40358e0c2ea1 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 10:04:02 +0000 Subject: [PATCH 025/370] emergent_testing/todo: report a test's name before running it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every runner reports a test only once it has finished, so a running test is invisible: a slow proof looks like a hung runner, the browser page counts completions rather than naming where it is, and — the case that matters — when a proof takes the process down, the last line printed is the last test that *succeeded* and the one that broke is never named. `Reporter` has no event for it: `result` takes a `SandboxResult`, so it cannot be called before there is one. The todo adds a start event and notes that the easy part is the event; the real question is terminal output under concurrency, which is the same question in both hosts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/report-before-running.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 fjs/emergent_testing/todo/report-before-running.md diff --git a/fjs/emergent_testing/todo/report-before-running.md b/fjs/emergent_testing/todo/report-before-running.md new file mode 100644 index 000000000..ba1eba76a --- /dev/null +++ b/fjs/emergent_testing/todo/report-before-running.md @@ -0,0 +1,75 @@ +## Report a test's name before running it, not only after + +**Priority:** P2 +**Status:** open + +### Problem + +Every runner reports a test only once it has finished. `fjs t` writes +`import("./a.proof.f.mjs").proof.x(): ok, 0.3 ms` after the fact, and the +browser page appends `PASS import("a").proof.x() (0.3 ms)` the same way. A test +that is *running* is invisible. + +Three things follow from that, and the third is the one that matters: + +- **A slow test looks like a hung runner.** Nothing distinguishes "this proof + has been going for ten seconds" from "the runner stopped", so the only way to + find the slow one is to wait for it to finish and read the duration. +- **Progress is a count, not a place.** The browser page says "1247 tests + completed…" while a reader wants to know *which* one it is on. +- **A crash loses the one fact worth having.** When a proof takes the process + down — a panic through the shared traversal, an out-of-memory, a stack + overflow, a runner bug — the last line printed is the last test that + *succeeded*, and the one that actually broke is never named. That is exactly + the case where a name is worth more than a result, and it is the case where + the current design has none. + +`Reporter` has no event for it: `result` is called with a `SandboxResult`, so it +cannot be called before there is one. + +### Preliminary design + +Add a `start` (or `begin`) event to `Reporter`, called with the file and path +before the leaf is sandboxed, and let each host decide what to do with it: + +- **`fjs t`** prints the name, then completes the line with `ok`/`error` and the + duration when the result lands — the standard runner shape, and the format + `fmtImport` already produces. Interleaving is the thing to get right: leaves + run concurrently, so a half-written line cannot be left open across another + test's output. Either the name and its outcome are one deferred line with the + name shown live elsewhere, or output is a two-column log that names the start + and closes it by identifier. +- **The browser page** renders a row in a pending state and settles it in place, + which is the same list it renders now with one more state per row. +- **`TestResult`** may not need to change at all: a start is an event, not a + result. Whether `report`/`reported` grow a sibling operation or the existing + one gains a status is part of the design. + +The `Reporter` change is small; the interleaving question is the real one, and +it is the same question in both hosts, which is an argument for settling it in +the shared core rather than twice. + +### Constraints + +- A start event must not cost a `sandbox` call or a clock read of its own: the + duration reported is still the sandboxed one. +- Concurrency stays. Naming a test before running it must not serialize the + suite to keep the output tidy. +- Whatever is emitted has to be as useful to an automated consumer as to a + reader — a start with no matching result is precisely the signal a crashed + run leaves behind, and a controller should be able to read it. + +### Tasks + +- [ ] Add the start event to `Reporter` and call it from `runModule` before the + leaf is sandboxed. +- [ ] Decide the terminal format for concurrent output, and prove it. +- [ ] Render a pending row in the browser page and settle it in place. +- [ ] Prove that a run killed mid-test leaves the running test's name behind. + +### Related + +- [Share the whole runner](share-the-whole-runner.md) — reporting is one of the + things each host still does its own way. +- [Hostile proof values](hostile-proof-values.md) — the crash case this would + make diagnosable, where today the run ends with no summary and no name. From 09bac1ff5cc6dceb87a5664a10d71ea96660c888 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:14:35 +0000 Subject: [PATCH 026/370] emergent_testing/browser: assert the yield on the queue all posts to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `allYieldsBetweenLaunches` queued a `setTimeout(0)` before calling `all` and asserted the timer had fired by the time `all` resolved. That reads as engine-neutral and is not: bun delivers every pending `MessageChannel` message before it runs a due timer, so the 59 yields `all` performs keep a `setTimeout(0)` queued behind them indefinitely. Confirmed directly — 59 yields, then 100000 more, and the timer never fires under bun 1.3.11, while node fires it on the first. The proof was calling a yielding `all` a non-yielding one, and bun CI failed on it. Queue a `MessageChannel` message instead, which is the same queue `all` posts to, so the assertion states the property every engine agrees on: a launch ends the task, so anything already queued runs before `all` resolves. Still load-bearing — deleting the yield from `runYielding` fails it under bun. effects/browser/module.mjs is unchanged; the browser behaviour measured in Chromium is what it was. Also files a todo for browser timer precision: `performance.now()` is coarsened to 100 us in Chromium and rounded and jittered to 1 ms in Firefox, which is at or above what a typical proof takes, so the page's per-proof durations are largely the clamp rather than a measurement. Changelog: no user-visible change. --- fjs/emergent_testing/browser/proof.mjs | 21 +++- fjs/emergent_testing/todo/browser-testing.md | 1 + fjs/emergent_testing/todo/timer-precision.md | 103 +++++++++++++++++++ 3 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 fjs/emergent_testing/todo/timer-precision.md diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 004af30b1..8e9041fdb 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -435,18 +435,29 @@ export const proof = { assertEq(results.length, 11) }, // `all` hands the event loop back between launches, which is the only - // thing that lets a page paint mid-suite: a timer queued before the call + // thing that lets a page paint mid-suite: a task queued before the call // has to run before it resolves. Without the yield every child settles - // on microtasks and no timer gets a turn — which is what this asserts, + // on microtasks and no task gets a turn — which is what this asserts, // since the effects below perform nothing. + // + // The task queued here is a `MessageChannel` message rather than a + // `setTimeout`, because the two are not interchangeable across engines. + // Bun delivers port messages until none are left before it runs a due + // timer, so 59 yields there leave a `setTimeout(0)` queued behind them + // and this proof would report a yielding `all` as a non-yielding one. + // Asserting on the queue `all` actually posts to states the property + // — that a launch ends the task, so anything already queued runs — in + // terms every engine agrees on. allYieldsBetweenLaunches: async () => { - let fired = false - setTimeout(() => { fired = true }, 0) + let delivered = false + const { port1, port2 } = new MessageChannel() + port1.onmessage = () => { port1.close(); delivered = true } + port2.postMessage(0) const many = [...new Array(60).keys()].map(i => pureOk(i)) const results = unwrap(await all(...many)) assertEq(results.length, 60) assertEq(results.map(unwrap).join(','), many.map((_, i) => i).join(',')) - assert(fired, 'all resolved without yielding to the event loop') + assert(delivered, 'all resolved without yielding to the event loop') }, }, } diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index 0e6eeece8..a3f008bc0 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -192,6 +192,7 @@ workers, or visual regression testing. - [`.f.mjs` proof discovery and coverage](f-mjs-test-and-coverage.md) - [Hostile thrown values and cross-realm promises](hostile-proof-values.md) +- [Browser timer precision](timer-precision.md) - [Explicit browser test controls](browser-test-controls.md) - [authored `.f.mjs` package support](../../ci/todo/f-mjs-package-support.md) - [project roadmap](../../../todo/plan/roadmap.md) diff --git a/fjs/emergent_testing/todo/timer-precision.md b/fjs/emergent_testing/todo/timer-precision.md new file mode 100644 index 000000000..b09ff1eb2 --- /dev/null +++ b/fjs/emergent_testing/todo/timer-precision.md @@ -0,0 +1,103 @@ +## Browser timer precision makes per-proof durations mostly noise + +**Priority:** P2 +**Status:** open + +### Problem + +`sandbox` measures every proof the same way in every host — read the clock, +run the body, read it again: + +```js +const before = performance.now() +// ... +return { result, duration: after - before } +``` + +That is right for `fjs t`, where `performance.now()` resolves to well under a +microsecond. It is not right in a browser, where the same call is deliberately +degraded as a Spectre and fingerprinting mitigation: + +- Chromium coarsens `performance.now()` to **100 µs** on an ordinary page, and + to 5 µs only when the page is cross-origin isolated (`COOP`/`COEP`). +- Firefox rounds to **1 ms** by default (`privacy.reduceTimerPrecision`) and + additionally *jitters* the value, so successive reads are not merely coarse + but non-deterministic. +- WebKit coarsens as well, and the exact figure has moved between releases. + +The numbers our own suite produces put almost every proof under those clamps: +a typical leaf in the CLI report is 0.03–0.2 ms. On an ordinary Chromium page +that is one clock tick or zero, and on Firefox it is zero or one whole +millisecond of jitter. So the browser page's `(0.3 ms)` column is not a +measurement of anything — it is the clamp, rendered per row. Worse, a *total* +built by summing thousands of such rows accumulates the rounding rather than +cancelling it, so the sum can be off by a large multiple in either direction +depending on which way each read rounded. + +Note this is not the same concern as +[`now`'s monotonicity](../../effects/browser/module.mjs), which is already +handled: `performance.timeOrigin + performance.now()` cannot go backwards. A +monotonic clock can still be a coarse one, and this is about the resolution. + +### Preliminary design + +Nothing here is decided; the point of the todo is to establish what is true +before changing the measurement. + +- **Measure the clamp rather than assume it.** A proof that reads the clock in + a tight loop and reports the smallest non-zero difference tells us the real + resolution in whatever browser is running, which is a fact the report could + carry alongside the durations. It is also the honest precondition for every + option below. +- **Report a resolution, not just a duration.** If the host clock ticks at + 100 µs, a row saying `0.1 ms` is claiming precision it does not have. The + report is serializable and consumed by controllers, so a `resolution` field + would let a consumer decide what is significant instead of guessing. +- **Accumulate over a group.** The idea raised when this was filed: time a + batch of leaves with one pair of reads and divide, so the clamp is amortized + across many proofs instead of applied to each. This is speculation — it + trades a per-test number for an average, it cannot attribute a slow proof, + and it interacts with concurrency, since `all` interleaves launches and a + group's wall time would then include siblings' work. Worth prototyping, + not worth assuming. +- **Cross-origin isolation.** Serving the eventual application root with + `COOP: same-origin` and `COEP: require-corp` buys Chromium's 5 µs clock and + is a header change in the shared controller, not a design change. It does + nothing for Firefox's jitter, and it constrains what the page may embed. +- **Consider not reporting a per-proof duration in the browser at all** if + none of the above yields a number worth printing. A column that is always + the clamp is worse than no column. + +### Constraints + +- `sandbox` is the operation that executes a proof body, and both runners must + agree on it exactly or a suite means different things in different hosts. + Any change to how it measures is a change to the shared contract, not a + browser-local tweak. +- The clock must stay monotonic. Whatever replaces or supplements + `performance.now()` cannot reintroduce wall-clock time. +- A duration must not cost a second `sandbox` call or an extra scheduling + boundary: the reads are adjacent today precisely so a scheduler cannot + interleave between them. +- Whatever the browser reports has to stay serializable and comparable to what + `fjs t` reports, or the two reports cannot be diffed. + +### Tasks + +- [ ] Measure the actual `performance.now()` resolution in Chromium, Firefox + and WebKit from inside the runner, and record the figures here. +- [ ] Decide whether the report carries the resolution, and whether a row + below it renders a duration at all. +- [ ] Prototype accumulated timing over a group of leaves and check what it + costs in attribution and what concurrency does to it. +- [ ] Check whether cross-origin isolation is worth the headers in the shared + controller. + +### Related + +- [Run FunctionalScript proofs inside real browsers](browser-testing.md) — the + report contract these durations belong to. +- [Report a test's name before running it](report-before-running.md) — the + other thing wrong with what a row shows. +- [Share the whole runner](share-the-whole-runner.md) — `sandbox` is shared, + so this is one decision, not two. From 6f5dd5977ad1222ed396751dc8bcec7caa7ed3df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:30:56 +0000 Subject: [PATCH 027/370] DESIGN: follow the example when porting a capability to a second context "Reuse code" is satisfiable while still getting the important half wrong: share a module, then give the new context its own rules, and the result looks unified but is two behaviours behind one name. Records that the existing implementation is the specification for a port, that a difference has to be justified rather than merely noticed, and that a problem the new context reveals is fixed for the shared code or recorded as an issue -- never worked around in one host. --- DESIGN.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index 2ee621b5a..8567a7b96 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -107,8 +107,47 @@ on top of the weaker design. 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. +- **Follow the example** — when the same capability already exists elsewhere, + match it before improving on it. See below. - **Avoid side effects and mutability.** +### Follow the example + +When a capability already exists somewhere in the repository and is being +brought to a second context — another host, another backend, another runner — +**the existing one is the specification.** Reproduce its behaviour first, +including the simplifications it made and the things it does not do. Only once +the second context matches the first is it worth asking whether either should +change. + +This is not the same as reusing code, and it is the part that is easy to skip +while believing the principle is satisfied. Sharing a module and then giving the +new context its own rules produces something that *looks* unified and is not: +two behaviours behind one name, which is worse than two implementations behind +two names, because nothing signals the difference. + +The rule has three consequences worth stating outright. + +**A difference has to be justified, not merely noticed.** "The new context can +do better here" is a reason to file an issue, not a reason to diverge inside a +port. The example may be simple *for a reason* that is not visible from inside +the new context — `fjs t` runs proofs one after another, and its report is +readable, attributable and reproducible because of it. + +**A problem the new context reveals is everyone's problem.** If porting exposes +that a measurement is inaccurate, that an error loses attribution, or that an +ordering is unspecified, then it was very likely already true of the example and +merely easier to see now. Fix it once, for both, as its own change — or record +it as an issue. Fixing it only in the new context leaves the two out of step and +hides the finding from the place that has had the defect longest. + +**Solve it for the shared code or not at all.** A workaround that lives in one +host is a fork with extra steps. Either the shared layer learns the answer, or +the issue stays open and honest. + +The order, then, is: reuse and match the example; land that; *then* take the +new problems one at a time, as changes that apply everywhere. + ### Exception to DRY: performance measurement Time measurement must capture immediately after an operation completes to avoid From 565f4cee73d8592ba608b563f6b0dc7b86a647d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:44:03 +0000 Subject: [PATCH 028/370] Revert the shared runner; keep what it taught Restores every code file to main. The unified runner worked -- one `runModuleMap`, an `effects/common` layer, a browser interpreter, 100% coverage, green CI, 3435 proofs executed in Chromium -- but the way it got there is not a way worth repeating, and the record of why is worth more than the code was. What is left is the knowledge: - `share-browser-console-runner.md` is restored and now carries a "How to do this" section: `fjs t` is the specification including the things it does not do; sharing modules while keeping per-host rules looks like success and is not; the browser is sequential for a reason; a problem the port reveals is everyone's problem and belongs in an issue, not in a workaround inside the port. It also traces the six review rounds that a single unrequested batch-size constant produced, all the way to the bun failure, and ends where copying the example would have started. - `DESIGN.md` section 4 gains "Follow the example", the general form of the same rule. - Four issues the attempt surfaced stay, rewritten to describe the code as it is on main rather than as the branch left it: `hostile-proof-values.md`, `imports-promises-realms.md`, `report-before-running.md`, `timer-precision.md`. No code changes: the diff against main is documentation only. --- changelog/unreleased/1737.md | 7 - fjs/effects/README.md | 35 - fjs/effects/browser/module.mjs | 139 ---- fjs/effects/common/module.f.mjs | 217 ------ fjs/effects/common/module.mjs | 89 --- fjs/effects/common/proof.f.mjs | 168 ----- fjs/effects/common/proof.mjs | 75 -- fjs/effects/common/types.ts | 143 ---- fjs/effects/memory/types.ts | 2 +- fjs/effects/node/module.f.mjs | 216 +++++- fjs/effects/node/module.mjs | 48 +- fjs/effects/node/proof.f.mjs | 75 +- fjs/effects/node/types.ts | 130 +++- fjs/emergent_testing/README.md | 44 -- fjs/emergent_testing/browser.mjs | 455 ++++++++++++ fjs/emergent_testing/browser/module.f.mjs | 140 ---- fjs/emergent_testing/browser/module.mjs | 211 ------ fjs/emergent_testing/browser/proof.f.mjs | 162 ----- fjs/emergent_testing/browser/proof.mjs | 649 +++++++++--------- .../browser/species.proof.mjs | 45 ++ fjs/emergent_testing/browser/types.ts | 74 -- fjs/emergent_testing/module.f.mjs | 120 +--- fjs/emergent_testing/proof.f.mjs | 62 +- .../todo/browser-test-controls.md | 4 +- fjs/emergent_testing/todo/browser-testing.md | 41 +- .../todo/hostile-proof-values.md | 79 ++- .../todo/imports-promises-realms.md | 25 +- .../todo/report-before-running.md | 23 +- .../todo/share-browser-console-runner.md | 246 +++++++ .../todo/share-the-whole-runner.md | 84 --- fjs/emergent_testing/todo/timer-precision.md | 23 +- fjs/emergent_testing/types.ts | 43 +- fjs/website/module.f.mjs | 2 +- fjs/website/todo/generate-website.md | 2 +- .../todo/website-preparation-program.md | 69 -- 35 files changed, 1590 insertions(+), 2357 deletions(-) delete mode 100644 changelog/unreleased/1737.md delete mode 100644 fjs/effects/browser/module.mjs delete mode 100644 fjs/effects/common/module.f.mjs delete mode 100644 fjs/effects/common/module.mjs delete mode 100644 fjs/effects/common/proof.f.mjs delete mode 100644 fjs/effects/common/proof.mjs delete mode 100644 fjs/effects/common/types.ts create mode 100644 fjs/emergent_testing/browser.mjs delete mode 100644 fjs/emergent_testing/browser/module.f.mjs delete mode 100644 fjs/emergent_testing/browser/module.mjs delete mode 100644 fjs/emergent_testing/browser/proof.f.mjs create mode 100644 fjs/emergent_testing/browser/species.proof.mjs delete mode 100644 fjs/emergent_testing/browser/types.ts create mode 100644 fjs/emergent_testing/todo/share-browser-console-runner.md delete mode 100644 fjs/emergent_testing/todo/share-the-whole-runner.md delete mode 100644 fjs/website/todo/website-preparation-program.md diff --git a/changelog/unreleased/1737.md b/changelog/unreleased/1737.md deleted file mode 100644 index ab9258d98..000000000 --- a/changelog/unreleased/1737.md +++ /dev/null @@ -1,7 +0,0 @@ -- **BREAKING CHANGES:** `emergent_testing`: `browser.mjs` moves to - `browser/module.mjs` and now shares `fjs t`'s proof semantics; - `runBrowserProofs` and `startBrowserTests` are gone, `startBrowserTestSources` - remains. Only `instanceof Promise` values are awaited, matching `fjs t` -- `effects`: the host-independent operations (`all`, `await`, `fetch`, `import`, - `now`, `sandbox`) move to `effects/common`, re-exported unchanged from - `effects/node`; `effects/browser` interprets them in a browser realm diff --git a/fjs/effects/README.md b/fjs/effects/README.md index 5d14a0005..9cc4cd487 100644 --- a/fjs/effects/README.md +++ b/fjs/effects/README.md @@ -144,41 +144,6 @@ conflated in either direction — a capability the runner merely lacks is answer with `NotImplemented`, never by killing the program, and a refusal to continue is an interruption, never dressed up as `NotImplemented`. -## Where an operation lives - -An operation belongs to the host that alone can perform it, and to -[`./common/`](./common/module.f.mjs) when no host owns it. `all`, `await`, -`fetch`, `import`, `now` and `sandbox` describe what a JavaScript *realm* can do -— hold a value, wait for a promise, measure a call, link a module — so the Node -runner, the browser runner and the virtual runner each implement the same -command at the same contract. `readFile`, `write`, `exec`, `createServer` and -`test` describe what a *host* can do, and stay in [`./node/`](./node/types.ts). - -The line is not bookkeeping. It is what lets a program state that it needs -nothing host-specific and then be run by either host: the browser proof runner -(`fjs/emergent_testing/browser/module.f.mjs`) performs only `CommonOp` plus two -operations of its own, which is why it and `fjs t` can share every line of proof -semantics between them. `./node/` re-exports every common name, so a consumer -that already imports one module for `readFile` keeps importing it for `sandbox`. - -**Part of the interpretation is common too**, and -[`./common/module.mjs`](./common/module.mjs) holds it: `sandbox`'s -`try`/`catch`-and-measure, `await`'s promise test, and the `io` wrapper that -turns a thrown value into an `IoError`. None of them touches a host — a bare -JavaScript realm has `Promise`, a clock and a `catch` — and `sandbox` in -particular is the operation that actually *executes* a proof body, so a runner -that spelled it its own way would make a test suite mean different things in -different hosts. The two runners did have it byte-identical, with a comment in -one saying it matched the other; a comment is not a mechanism. - -An interpreter lives beside the host it interprets — [`./node/module.mjs`](./node/module.mjs), -[`./browser/module.mjs`](./browser/module.mjs) — and the browser one implements -`CommonOp` and nothing else. There is no browser filesystem and no browser -stdout, and inventing spellings for them would describe a host that does not -exist; a page that needs an operation of its own composes its handlers on top of -that map, which is why `browserOperationMap` takes the composed runner rather -than closing over one of its own. - ## Leaving the layer Not every consumer is ready to compose. Two named policies exist so that a site diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs deleted file mode 100644 index 799da2792..000000000 --- a/fjs/effects/browser/module.mjs +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Browser effect runner: interprets the host-independent operations - * (`../common/types.ts`) against a browser realm. - * - * It is the browser's counterpart of [`../node/module.mjs`](../node/module.mjs) - * and deliberately implements **only** `CommonOp`. There is no browser - * filesystem, no subprocess and no stdout to interpret, and inventing browser - * spellings for those would describe a host that does not exist; a page needing - * something of its own — a DOM to render into, a report to publish — composes - * its handlers on top of this map rather than finding them in it. - * - * The module has no Node dependency of any kind, so a page links it as an - * ordinary ES module with no bundling or transpilation. - * - * @module - * - * @import { Effect, ToAsyncOperationMap } from '../types.ts' - * @import { Result } from '../../types/result/types.ts' - * @import { CommonOp, Module } from '../common/types.ts' - */ - -import { awaitPromise, io, sandbox } from '../common/module.mjs' -import { ok } from '../../types/result/module.f.mjs' -import { toVec } from '../../types/uint8array/module.f.mjs' - -/** - * An effect runner over the operations this map is spread into. `all` runs its - * children through it rather than through a runner of its own, so an effect - * nested inside `all` reaches every handler the caller composed — not just the - * common ones. - * - * @typedef {(effect: Effect) => Promise>} CommonRun - */ - -/** - * Links a module in the page's realm. Injected so a caller can report loading - * progress, resolve a specifier against an application root, or drive the - * runner from a proof without a network; the default is the realm's own - * dynamic `import`. - * - * @typedef {(source: string) => Promise} BrowserImporter - */ - -/** - * Hands the event loop back, so the browser gets a turn. - * - * **Not `setTimeout`.** It clamps to 4 ms once nested, and a yield between every - * launch across a few thousand proofs is then minutes of pure clamp — measured - * at 58 s against 40 s on the real suite. That cost is what once made grouping - * the launches look necessary. A `MessageChannel` message is an ordinary task - * with no clamp, so the same per-launch yield costs about 3%. - * - * @type {() => Promise} - */ -const yieldToLoop = () => new Promise(resolve => { - const { port1, port2 } = new MessageChannel() - port1.onmessage = () => { port1.close(); resolve(undefined) } - port2.postMessage(0) -}) - -/** - * Starts every effect, handing the event loop back between one launch and the - * next, and answers each `Result` in the order the effects were given. - * - * **A browser needs a real task boundary to paint, and only `all` can give it - * one.** Every operation resolves through a microtask, and a browser cannot - * paint between microtasks, so without this the whole suite is a single task: - * measured on the real suite, the first result appears at 39.8 s of a 39.7 s - * run — nothing at all until the end, and no faster for it. What a launch does - * is exactly the work worth bounding, because a proof body runs synchronously - * inside `sandbox` before that handler's first `await`. - * - * **Every effect is started before any is awaited**, which is not a detail. - * `all` promises its children run concurrently, and awaiting one before - * starting the next would break that promise rather than delay it: a child - * waiting on something a later sibling produces would wait for a sibling that - * is never started, and the run would hang with no report — on a graph the Node - * runner completes. `all` says its children run concurrently and that it - * answers every `Result`; it does not say they start in the same task, which is - * what leaves the scheduling to the runner. The Node runner has no frame to - * paint and starts them all at once. - * - * There is deliberately **no batch size**. Grouping launches was a workaround - * for `setTimeout`'s clamp, and a count is the wrong measure anyway — proofs - * differ in cost by orders of magnitude, so a group of ten fast ones wastes a - * boundary while a group holding one slow one stalls regardless. With an - * unclamped yield there is no constant left to tune. - * - * @template T - * @template E - * @param {CommonRun} run - * @param {readonly Effect[]} effects - * @returns {Promise[]>} - */ -const runYielding = async (run, effects) => { - /** @type {readonly Promise>[]} */ - let started = [] - for (const effect of effects) { - if (started.length !== 0) { await yieldToLoop() } - started = [...started, run(effect)] - } - return Promise.all(started) -} - -/** - * The browser's handlers for the host-independent operations. - * - * `run` is the composed runner the caller builds — the one that also knows the - * caller's own operations — so `all` schedules its children through it. Passing - * it in rather than closing over a runner defined here is what keeps this map - * composable: a page adds handlers, and the effects nested inside `all` still - * reach them. - * - * @type {(run: CommonRun, importer?: BrowserImporter) => ToAsyncOperationMap} - */ -export const browserOperationMap = (run, importer = source => import(source)) => ({ - all: async (...effects) => ok(await runYielding(run, effects)), - await: async p => ok(await awaitPromise(p)), - fetch: url => io(async () => { - const response = await globalThis.fetch(url) - if (!response.ok) { - throw new Error(`Fetch error: ${response.status} ${response.statusText}`) - } - return toVec(new Uint8Array(await response.arrayBuffer())) - }), - // A synchronous throw from the importer — a specifier the realm rejects - // before it ever starts loading — is a load failure like any other, so it - // is caught here rather than escaping the effect it belongs to. - import: path => io(async () => importer(path)), - // `performance.timeOrigin + performance.now()`, not `Date.now()`. The - // operation means the same thing either way — milliseconds since the epoch, - // as the Node runner answers — but this one cannot go backwards. A suite - // runs for minutes, an NTP correction lands inside one, and the report's - // duration is the difference between two of these reads: with wall-clock - // time that difference can come out negative or inflated, which is what the - // deleted browser runner avoided by measuring in `performance.now()`. - now: async () => ok(performance.timeOrigin + performance.now()), - sandbox: async f => ok(await sandbox(f)), -}) diff --git a/fjs/effects/common/module.f.mjs b/fjs/effects/common/module.f.mjs deleted file mode 100644 index a513e58db..000000000 --- a/fjs/effects/common/module.f.mjs +++ /dev/null @@ -1,217 +0,0 @@ -/** - * The operations no host owns, and the helpers that read their error channel. - * - * `all` / `allOk` / `both` (concurrency), `await` (promise resolution), - * `fetch`, `import_`, `now` and `sandbox` each describe something a JavaScript - * realm can do on its own, so every runner implements them the same way: the - * Node runner in [`../node/module.mjs`](../node/module.mjs), the browser runner - * in [`../browser/module.mjs`](../browser/module.mjs), and the virtual one in - * [`../node/virtual/module.f.mjs`](../node/virtual/module.f.mjs). - * - * They lived in `../node/module.f.mjs`, which re-exports every name below so an - * existing importer keeps naming one module. What is genuinely Node's — the - * filesystem, streams, subprocesses, HTTP, an external test framework — stayed - * there. - * - * See [`./types.ts`](./types.ts) for the type-level API. - * - * @module - * - * @import { Effect, Func, NotImplemented, Operation } from '../types.ts' - * @import { Result } from '../../types/result/types.ts' - * @import { All, Await, Fetch, Import, IoChannel, IoError, IoErrorInfo, Now, Sandbox } from './types.ts' - */ - -import { do_, mapStep, pure, step } from '../module.f.mjs' -import { ok as resultOk, unwrap } from '../../types/result/module.f.mjs' - -/** - * Builds a normalized host error. The constructor exists so the shape is - * written once: every runner reports its failures through it, and a consumer - * matching on `'ioError'` knows what the payload holds. - * - * @type {(info: IoErrorInfo) => IoError} - */ -export const ioError = info => ['ioError', info] - -/** - * Normalizes a **thrown** value into an {@link IoError}: the OS error code when - * the host attached a string one, and a message that is the `Error`'s own or - * the value's string form. - * - * This is the boundary where an impure runner's `catch` becomes ordinary effect - * data. Nothing past it sees the thrown object, which is the point — a stack, a - * `cause`, and arbitrary own properties do not survive a wire hop, and a - * program that branched on them would be reading the host's implementation - * rather than the operation's contract. - * - * @type {(e: unknown) => IoError} - */ -export const toIoError = e => { - const message = e instanceof Error ? e.message : String(e) - if (typeof e !== 'object' || e === null || !('code' in e) || typeof e.code !== 'string') { - return ioError({ message }) - } - return ioError({ code: e.code, message }) -} - -/** - * True if `e` is a "file or directory does not exist" (`ENOENT`) error. - * - * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which - * {@link toIoError} keeps; the virtual interpreter reports the same code for - * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh - * store) while propagating genuine failures (permissions, corruption) rather - * than masking them. - * - * A {@link NotImplemented} is never "not found": a runner that cannot perform - * the operation has not looked for the path at all, so the two must not - * collapse into one benign branch — which is exactly what a bare `unknown` - * error channel used to allow. - * - * @type {(e: IoChannel) => boolean} - */ -export const isNotFound = ([tag, payload]) => - tag === 'ioError' && payload.code === 'ENOENT' - -/** - * Renders a channel error as a human line: an {@link IoError}'s own message, or - * the command name a runner could not dispatch. - * - * @type {(e: IoChannel) => string} - */ -export const errorMessage = ([tag, payload]) => - tag === 'notImplemented' ? `operation not implemented: ${payload}` : payload.message - -/** - * Renders a channel error for a **remote** caller: the command name for a - * {@link NotImplemented}, the OS error code for an `IoError`, and nothing else. - * - * {@link errorMessage} is for the operator of the program, who is entitled to - * the host's own words — including the path that failed. A protocol client is - * not, and the difference is not stylistic: `payload.message` is where the - * host puts the absolute path it could not read, so answering an MCP tool call - * with it publishes the server's filesystem layout to whoever is on the other - * end. The code (`ENOENT`, `EACCES`) says *what* went wrong without saying - * *where*, which is the part a client can act on anyway. - * - * A host that attached no code leaves nothing safe to forward, so the answer is - * the bare kind. That is deliberate: guessing which part of a free-text message - * is path-free is exactly the mistake this exists to prevent. - * - * @type {(e: IoChannel) => string} - */ -export const errorSummary = ([tag, payload]) => - tag === 'notImplemented' - ? `operation not implemented: ${payload}` - : payload.code === undefined ? 'io error' : `io error: ${payload.code}` - -// all - -/** - * To run the operation `O` should be known by the runner/engine. - * This is the reason why we merge `O` with `All` in the resulting effect. - */ -export const all = - // `Func` cannot express a variadic generic operation, so the declared type - // is written out here and `do_`'s is set aside. - /** @type {(...a: readonly Effect[]) => Effect[], NotImplemented>} */ - (/** @type {unknown} */ (do_('all'))) - -/** - * Collapses a list of results into a result of the list, keeping the **first** - * error in list order and discarding the later ones. - * - * Keeping one is what makes this a `Result` rather than a report: the callers - * that need it are chains, and a chain has one error channel. A site that wants - * every failure wants a different return type and should not reach for this. - * - * @type {(list: readonly Result[]) => Result} - */ -const okList = list => { - for (const r of list) { - if (r[0] === 'error') { return r } - } - return resultOk(list.map(unwrap)) -} - -/** - * {@link all} in the `ok` channel: collects the values when every effect - * succeeded, and answers with the first failure otherwise. - * - * `all` alone cannot serve a fallible chain. Its envelope is the runner's - * (`OpResult`, saying whether the *operation* could be dispatched), so handing - * it `Effect`s nests one `Result` inside another and the caller receives - * `readonly Result[]`. That has to be collapsed before the chain can - * `step` again, and a continuation that forgets to is the value-discarding - * hazard this migration exists to remove — one level in, where it is harder to - * see. - * - * **Every effect still runs.** The short-circuit is in the *result*, not in the - * execution: `all` performs them concurrently and this reads the answers once - * they are all in, so a failure does not cancel its siblings the way it stops - * the sequential `forEachStep` in `../module.f.mjs`. The error channel - * unions the runner's - * `NotImplemented` with the effects' own `E` for the same reason every other - * step does — either can be what went wrong. - * - * @type {(...a: readonly Effect[]) => Effect} - */ -export const allOk = (...a) => - step(all(...a), rs => pure(okList(rs))) - -/** - * @template {Operation} O0 - * @template T0 - * @template E0 - * @param {Effect} a - * @returns {(b: Effect) => Effect, Result], NotImplemented>} - */ -export const both = a => b => - /** @type {any} */ (all)(a, b) - -// fetch - -/** @type {Func} */ -export const fetch = do_('fetch') - -// import - -/** @type {Func} */ -export const import_ = do_('import') - -// now - -/** @type {Func} */ -export const now = do_('now') - -// sandbox - -/** - * Runs a plain synchronous function in an isolated, measured environment. - * - * Combines try/catch and high-resolution timing into a single atomic operation. - * Only plain synchronous functions are accepted — no effects, no promises. - * - * Using a single operation rather than separate `TryCatch` + `Perf` effects is - * necessary for correctness: effects execute as async tasks, so the scheduler - * can insert arbitrary work between two separate timing calls, making the - * measured delta inaccurate. Here the clock reads happen synchronously around - * the function call with nothing in between. - * - * Future parameters (time limit, memory limit) can be added to the payload - * without breaking the API. Worker-based implementations can enforce hard - * limits via worker termination. - * - * @see {@link SandboxResult} - * - * @type {Func} - */ -export const sandbox = do_('sandbox') - -/** @type {Func} */ -const awaitPromise = do_('await') - -/** @type {(p: unknown) => Effect} */ -export const awaitIfPromise = p => - mapStep(awaitPromise(p), ([x]) => x) diff --git a/fjs/effects/common/module.mjs b/fjs/effects/common/module.mjs deleted file mode 100644 index 61240f061..000000000 --- a/fjs/effects/common/module.mjs +++ /dev/null @@ -1,89 +0,0 @@ -/** - * The impure half of the host-independent operations: the three handlers every - * runner would otherwise write for itself. - * - * `../common/module.f.mjs` holds the *constructors* for `all`, `await`, `fetch`, - * `import`, `now` and `sandbox`; this holds the parts of their *interpretation* - * that are the same wherever they run. Nothing here touches a host: `sandbox` - * needs a `try`/`catch`, a clock and `Promise`, `await` needs `Promise`, and - * `io` needs a `catch` and the normalizer — all of which a bare JavaScript realm - * has. What differs between hosts is `fetch`, `import`, the clock's epoch and - * the concurrency policy, and those stay in each runner. - * - * It exists because the two runners had `sandbox`, `io` and the `await` body - * byte-identical, with a comment in one saying it matched the other. That is the - * drift this layer is meant to remove, and a comment is not a mechanism. - * - * @module - * - * @import { IoResult, SandboxResult } from './types.ts' - * @import { Result } from '../../types/result/types.ts' - */ - -import { toIoError } from './module.f.mjs' -import { error, ok } from '../../types/result/module.f.mjs' -import { asyncTryCatch } from '../../types/result/module.mjs' - -/** - * Performs host IO, reporting a thrown failure as an {@link IoResult} error. - * - * The one place where an exception becomes ordinary effect data, normalized so - * that nothing past it sees the thrown object — a stack, a `cause` and - * arbitrary own properties do not survive a wire hop. - * - * @template T - * @param {() => Promise} f - * @returns {Promise>} - */ -export const io = async f => { - const r = await asyncTryCatch(f) - return r[0] === 'ok' ? r : error(toIoError(r[1])) -} - -/** - * Runs `f` and measures it: a genuine `Promise` is awaited and a rejection is - * caught, and any other value — a proof tree carrying a `then` property - * included — is the result as it stands. - * - * **This is the operation that actually executes a proof body**, so every runner - * has to agree on it exactly or a test suite means different things in different - * hosts. That is why it is here rather than written once per runner: the two - * copies it replaces were identical, and nothing but a review would have caught - * them drifting apart. - * - * The clock is read either side of the call with nothing in between, which is - * the whole reason `sandbox` is one operation rather than a `tryCatch` and a - * `now` a scheduler could interleave. - * - * @template T - * @param {() => T} f - * @returns {Promise>} - */ -export const sandbox = async f => { - /** @type {Result} */ - let result - let after - const before = performance.now() - try { - let p = f() - after = performance.now() - if (p instanceof Promise) { - p = await p - after = performance.now() - } - result = ok(p) - } catch (e) { - after = performance.now() - result = error(e) - } - return { result, duration: after - before } -} - -/** - * Resolves a real `Promise` and hands anything else back untouched, in the - * one-element tuple the `await` operation answers with. - * - * @type {(p: unknown) => Promise} - */ -export const awaitPromise = async p => - [p instanceof Promise ? await p : p] diff --git a/fjs/effects/common/proof.f.mjs b/fjs/effects/common/proof.f.mjs deleted file mode 100644 index 1ea0435a9..000000000 --- a/fjs/effects/common/proof.f.mjs +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Proofs for the host-independent operations and the helpers that read their - * error channel. - * - * The operations are proved against a stand-in interpreter declared here rather - * than against a host runner: what this module owns is the *constructors* and - * the `ok`-channel collapse, and a proof that reached for `../node/virtual` - * would be reading a Node runner's answers to decide whether `all` builds the - * right node. Each host runner proves its own handlers — `../node/proof.f.mjs` - * for the virtual and Node ones, `../../emergent_testing/browser/proof.mjs` for - * the browser one. - * - * @import { Effect } from '../types.ts' - * @import { Result } from '../../types/result/types.ts' - * @import { MemOperationMap, RunInstance } from '../mock/types.ts' - * @import { CommonOp, SandboxResult } from './types.ts' - */ - -import { assert, assertEq } from '../../asserts/module.f.mjs' -import { - all, allOk, awaitIfPromise, both, errorMessage, errorSummary, fetch, import_, - ioError, isNotFound, now, sandbox, toIoError, -} from './module.f.mjs' -import { run as mockRun } from '../mock/module.f.mjs' -import { error, ok, unwrap } from '../../types/result/module.f.mjs' -import { vec8 } from '../../types/bit_vec/module.f.mjs' - -/** The one number the stand-in clock ever answers. */ -const fixedNow = 1_700_000_000 - -/** @type {MemOperationMap} */ -const map = { - all: (...a) => state => [state, ok(a.map(i => common(state)(i)[1]))], - await: p => state => [state, ok([p])], - fetch: url => state => [ - state, - url === 'ok' ? ok(vec8(0x2An)) : error(ioError({ message: `cannot fetch ${url}` })), - ], - import: source => state => [ - state, - source === 'ok' ? ok({ value: 1 }) : error(ioError({ code: 'ENOENT', message: source })), - ], - now: () => state => [state, ok(fixedNow)], - // The same pass-through the virtual Node runner uses: a fixture returns the - // `SandboxResult` it wants reported, so an outcome is dictated rather than - // measured. - sandbox: f => state => [state, ok(/** @type {SandboxResult} */ (f()))], -} - -/** @type {RunInstance} */ -const common = mockRun(map) - -/** @type {(e: Effect) => Result} */ -const run = e => common(null)(e)[1] - -export const proof = { - // The one boundary where a runner's `catch` becomes effect data: whatever - // was thrown is reduced to a code (when the host attached a string one) - // and a message. - toIoError: { - error: () => { - assertEq(toIoError(new Error('boom'))[1].message, 'boom') - }, - withCode: () => { - const [, info] = toIoError(Object.assign(new Error('missing'), { code: 'ENOENT' })) - assertEq(info.code, 'ENOENT') - assertEq(info.message, 'missing') - }, - // A thrown non-`Error` still normalizes: the value's string form is the - // message, and there is no code to carry. - string: () => { - const [, info] = toIoError('plain') - assertEq(info.code, undefined) - assertEq(info.message, 'plain') - }, - null: () => { - assertEq(toIoError(null)[1].message, 'null') - }, - // An object whose `code` is not a string is not an OS error code, so it - // is dropped rather than carried as one. - nonStringCode: () => { - assertEq(toIoError({ code: 42 })[1].code, undefined) - }, - noCode: () => { - assertEq(toIoError({})[1].code, undefined) - }, - }, - isNotFound: { - enoent: () => { - assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) - }, - otherCode: () => { - assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) - }, - // A runner that cannot perform the operation has not looked for the - // path at all, so a missing handler is never "not found". - notImplemented: () => { - assert(!isNotFound(['notImplemented', 'readFile'])) - }, - }, - errorMessage: { - io: () => { - assertEq(errorMessage(ioError({ message: 'disk full' })), 'disk full') - }, - notImplemented: () => { - assertEq(errorMessage(['notImplemented', 'readFile']), 'operation not implemented: readFile') - }, - }, - errorSummary: { - // The distinction that matters: `errorMessage` hands back the host's - // words, which is where the path lives; `errorSummary` never does. - io: () => { - assertEq(errorSummary(ioError({ code: 'ENOENT', message: "no such file or directory, scandir '/home/u/.cas'" })), 'io error: ENOENT') - }, - ioWithoutCode: () => { - assertEq(errorSummary(ioError({ message: "cannot read '/home/u/.cas'" })), 'io error') - }, - notImplemented: () => { - assertEq(errorSummary(['notImplemented', 'readdir']), 'operation not implemented: readdir') - }, - }, - // `all` answers each effect's whole `Result`: its own envelope says only - // whether the operation could be dispatched. - all: () => { - const r = unwrap(run(all(fetch('ok'), fetch('no')))) - assertEq(r.length, 2) - assertEq(r[0]?.[0], 'ok') - assertEq(r[1]?.[0], 'error') - }, - allOk: { - // The collapse a fallible chain wants: values when every effect - // succeeded... - collects: () => { - assertEq(unwrap(run(allOk(now(), now()))).join(','), `${fixedNow},${fixedNow}`) - }, - // ...and the first failure in list order otherwise. - firstError: () => { - const r = run(allOk(fetch('no'), fetch('worse'))) - assert(r[0] === 'error', r) - assertEq(errorMessage(r[1]), 'cannot fetch no') - }, - }, - both: () => { - const [a, b] = unwrap(run(both(now())(import_('ok')))) - assertEq(unwrap(a ?? error(0)), fixedNow) - assertEq(unwrap(b ?? error(0)).value, 1) - }, - import: { - linked: () => { - assertEq(unwrap(run(import_('ok'))).value, 1) - }, - missing: () => { - const r = run(import_('nope')) - assert(r[0] === 'error', r) - assert(isNotFound(r[1]), r[1]) - }, - }, - sandbox: () => { - const { result, duration } = unwrap(run(sandbox(() => ({ result: ok(7), duration: 3 })))) - assertEq(unwrap(result), 7) - assertEq(duration, 3) - }, - // A promise is the runner's business, so what the constructor owns is - // unwrapping the one-element tuple the operation answers with. - awaitIfPromise: () => { - assertEq(unwrap(run(awaitIfPromise(5))), 5) - }, -} diff --git a/fjs/effects/common/proof.mjs b/fjs/effects/common/proof.mjs deleted file mode 100644 index a2b3a4f30..000000000 --- a/fjs/effects/common/proof.mjs +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Proofs for the impure half of the host-independent operations. - * - * These three handlers are what every runner would otherwise write for itself, - * so they are proved here rather than only through whichever runner happens to - * call them — the duplication this module removed was invisible precisely - * because each copy was covered by its own host's proofs. - * - * @import { Result } from '../../types/result/types.ts' - */ - -import { assert, assertEq } from '../../asserts/module.f.mjs' -import { awaitPromise, io, sandbox } from './module.mjs' -import { errorMessage } from './module.f.mjs' -import { unwrap } from '../../types/result/module.f.mjs' - -export const proof = { - io: { - value: async () => { - assertEq(unwrap(await io(async () => 7)), 7) - }, - // The one boundary where an exception becomes ordinary effect data. - thrown: async () => { - const r = await io(async () => { throw Object.assign(new Error('nope'), { code: 'ENOENT' }) }) - assert(r[0] === 'error', r) - assertEq(errorMessage(r[1]), 'nope') - assertEq(r[1][0], 'ioError') - }, - }, - sandbox: { - value: async () => { - const { result, duration } = await sandbox(() => 1) - assertEq(unwrap(result), 1) - assert(duration >= 0, duration) - }, - thrown: async () => { - const { result } = await sandbox(() => { throw new Error('boom') }) - assert(result[0] === 'error', result) - assertEq(/** @type {Error} */ (result[1]).message, 'boom') - }, - // A real promise is awaited, and its rejection is the failure — which is - // the rule every runner has to agree on, since this is the operation - // that executes a proof body. - promise: async () => { - // The thunk is annotated because `Sandbox` declares - // `SandboxResult` while every runner resolves a real promise - // before answering, so the declared value type is `Promise` - // where the runtime value is `2`. - /** @type {() => unknown} */ - const resolves = () => Promise.resolve(2) - const { result } = await sandbox(resolves) - assertEq(unwrap(result), 2) - }, - rejected: async () => { - const { result } = await sandbox(() => Promise.reject(new Error('later'))) - assert(result[0] === 'error', result) - assertEq(/** @type {Error} */ (result[1]).message, 'later') - }, - // ...and an ordinary object carrying a `then` is a value, never a - // thenable to adopt. - thenable: async () => { - const value = { then: () => undefined } - const { result } = await sandbox(() => value) - assertEq(unwrap(result), value) - }, - }, - awaitPromise: { - promise: async () => { - assertEq((await awaitPromise(Promise.resolve(3)))[0], 3) - }, - plainValue: async () => { - assertEq((await awaitPromise(3))[0], 3) - }, - }, -} diff --git a/fjs/effects/common/types.ts b/fjs/effects/common/types.ts deleted file mode 100644 index e4acbc550..000000000 --- a/fjs/effects/common/types.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Types for the operations no host owns. - * - * Every operation declared here describes something a JavaScript realm can do - * on its own — hold a value, wait for a promise, measure a call, link a module, - * fetch a URL — so a Node runner, a browser runner, and the virtual runner can - * each implement the same command with the same contract. What is genuinely - * Node's — streams, the filesystem, subprocesses, an external test framework — - * stays in [`../node/types.ts`](../node/types.ts), which re-exports these so an - * existing importer keeps naming one module. - * - * @module - */ - -import type { Vec } from '../../types/bit_vec/types.ts' -import type { Effect, NotImplemented } from '../types.ts' -import type { Result } from '../../types/result/types.ts' -import type { StringMap } from '../../types/object/types.ts' - -/** - * A host failure, normalized: whatever the runtime threw reduced to a - * serializable record. `code` is the OS error code when the host supplied one - * (`'ENOENT'`, `'EEXIST'`), absent otherwise. - * - * It is a tagged tuple for the same reason {@link NotImplemented} is — the two - * share an error channel, and the tag is what tells them apart. That - * distinction is the whole reason this type exists: with a bare `unknown` - * error, `NotImplemented | unknown` collapses to `unknown` and a program can no - * longer tell "this runner cannot do it" from "the host tried and failed". - * - * Normalizing also keeps the channel serializable. A thrown `Error` carries a - * stack, a `cause`, and arbitrary own properties; none of it survives a wire - * hop, and a runner in another process could not reproduce it. - */ -export type IoError = readonly['ioError', IoErrorInfo] - -export type IoErrorInfo = { - readonly code?: string - readonly message: string -} - -/** - * The result of an operation with no failures of its own: it either produces - * its value or reports that the runner does not implement it. - * - * Every operation's return type is a `Result`, including the ones that cannot - * fail on their own terms — an operation left on a raw contract would be a hole - * in the error channel, and a runner may omit a handler for any of them. - */ -export type OpResult = Result - -/** - * The error channel of anything that performs host IO: a normalized host - * failure, or the report that the runner does not implement the operation. - * - * It is one name rather than a union spelled at each site, and that is a - * migration property rather than brevity. An effect that does no IO *yet* is - * one added `readFile` away from doing some, and if each signature names its - * own errors, that one change walks up every enclosing signature — the failure - * mode that sank `throws` clauses elsewhere, where engineers eventually - * declared everything throwing rather than maintain the cascade. Declaring the - * standard channel once is that concession made deliberately: an IO-touching - * effect says it fails *the way node IO fails*, and gaining a new way to do so - * changes nothing above it. - * - * It is not a licence to widen. An operation with failures of its own extends - * the channel (`IoChannel | ParseError`), and a computation whose errors are - * genuinely narrower should say so — this is the default for IO, not a ceiling. - */ -export type IoChannel = NotImplemented | IoError - -/** - * The result of an operation that performs host IO: its value, a normalized - * host failure, or the missing-handler report. - */ -export type IoResult = Result - -// all - -/** - * Runs its effects concurrently and answers each one's whole `Result`. - * - * The nesting is deliberate and belongs to the runner: this envelope says - * whether `all` itself could be dispatched, and each inner `Result` is what - * that effect answered. `allOk` (`./module.f.mjs`) is the collapse a fallible - * chain wants. - */ -export type All = ['all', (...effects: Effect[]) => OpResult[]>] - -// fetch - -export type Fetch = ['fetch', (url: string) => IoResult] - -// import - -export type Module = StringMap - -export type Import = ['import', (path: string) => IoResult] - -// now - -export type Now = readonly['now', () => OpResult] - -// sandbox - -/** - * The outcome of a `Sandbox` operation. - * - * `result` carries either `['ok', value]` or `['error', thrown]`. `duration` - * is a floating-point millisecond count with up to microsecond precision, - * matching `performance.now()` directly. Additional fields (allocated memory, - * max stack depth, coverage) may be added in future without breaking consumers. - */ -export type SandboxResult = { - readonly result: Result - /** - * Elapsed time in milliseconds (microsecond precision via `performance.now()`). - * The virtual runner returns `0` for deterministic tests. - */ - readonly duration: number -} - -export type Sandbox = readonly['sandbox', (f: () => T) => OpResult>] - -/** - * Resolves the return value of a test function inside the effect runner. - * If `p` is a real `Promise`, it is awaited and rejections propagate as - * throws. If `p` is any other value it is returned as-is. Plain thenables - * (objects with a `.then` method that are not `instanceof Promise`) are - * treated as ordinary values — not awaited. See `fjs/dev/tf/README.md`. - */ -export type Await = readonly['await', (p: unknown) => OpResult] - -/** - * The operations every runner is expected to be able to implement. - * - * A host runner's operation set is this union plus whatever its host adds: - * `NodeOp` is `CommonOp | MemOp | Fs | Http | …`, and the browser interpreter - * in [`../browser/module.mjs`](../browser/module.mjs) implements exactly this - * set against the browser realm. Naming it once is what lets a program say it - * needs nothing host-specific, and be run by either. - */ -export type CommonOp = All | Await | Fetch | Import | Now | Sandbox diff --git a/fjs/effects/memory/types.ts b/fjs/effects/memory/types.ts index cc72052a8..844dbb80c 100644 --- a/fjs/effects/memory/types.ts +++ b/fjs/effects/memory/types.ts @@ -6,7 +6,7 @@ import type { Phantom } from '../../types/phantom/types.ts' import type { Nominal } from '../../types/nominal/types.ts' -import type { OpResult } from '../common/types.ts' +import type { OpResult } from '../node/types.ts' /** Nominal brand version for memory keys. */ export type _MemKeyHash = '3f114fa6036a8da026b827f0c3e6d901f5e81ad9a320e431ccce31451892d286' diff --git a/fjs/effects/node/module.f.mjs b/fjs/effects/node/module.f.mjs index 50344ea11..5d1c6dddd 100644 --- a/fjs/effects/node/module.f.mjs +++ b/fjs/effects/node/module.f.mjs @@ -1,14 +1,10 @@ /** * Node.js effect operations: filesystem (`mkdir`, `readFile`, `readdir`, * `writeFile`, `rm`, `access`, plus the `readUtf8File`/`writeUtf8File` text - * helpers), HTTP (`createServer`, `listen`), subprocess `exec`, `log`/`error` - * (wrappers over `write`), `read`/`readLine`, `randomInt` and `forever`; defines - * the `NodeOp`/`NodeProgram` types used by the Node runner. - * - * The operations no host owns — `all`/`allOk`/`both`, `await`, `fetch`, - * `import_`, `now`, `sandbox`, and the `IoError` helpers — moved to - * [`../common/module.f.mjs`](../common/module.f.mjs) so the browser runner can - * link them, and are re-exported here unchanged. + * helpers), networking (`fetch`, `createServer`, `listen`), + * subprocess `exec`, `log`/`error` (wrappers over `write`), `import_`, `now`, + * `sandbox`, `forever`, and `all`/`both` parallelism; defines the + * `NodeOp`/`NodeProgram` types used by the Node runner. * * See `./types.ts` for the type-level API. * @@ -18,8 +14,7 @@ * @import { Result } from '../../types/result/types.ts' * @import { Commands, CommandSet, Effect, Func, NotImplemented, Operation } from '../types.ts' * @import { List } from '../list/types.ts' - * @import { IoError } from '../common/types.ts' - * @import { All, Access, Console, CreateExclusive, CreateServer, Dirent, Engine, Env, Exec, ExecResult, FileStat, Forever, Fs, Headers, Http, IncomingMessage, IoChannel, Listen, MakeDirectoryOptions, Mkdir, NodeOp, NodeProgramOptions, RandomInt, Read, ReadBytes, ReadConsoles, ReadFile, Readdir, ReaddirOptions, RequestListener, Rename, Rm, SandboxResult, Server, ServerResponse, Stat, Test, TestContext, TestFn, Write, WriteBytes, WriteConsoles, WriteFile, _UtfList, _WriteLoop } from './types.ts' + * @import { All, Access, Await, Console, CreateExclusive, CreateServer, Dirent, Engine, Env, Exec, ExecResult, Fetch, FileStat, Forever, Fs, Headers, Http, IncomingMessage, Import, IoChannel, IoError, IoErrorInfo, Listen, MakeDirectoryOptions, Mkdir, Module, Now, NodeOp, NodeProgramOptions, RandomInt, Read, ReadBytes, ReadConsoles, ReadFile, Readdir, ReaddirOptions, RequestListener, Rename, Rm, Sandbox, SandboxResult, Server, ServerResponse, Stat, Test, TestContext, TestFn, Write, WriteBytes, WriteConsoles, WriteFile, _UtfList, _WriteLoop } from './types.ts' */ import { utf8, utf8ToString } from '../../text/module.f.mjs' @@ -27,23 +22,20 @@ import { toCodePointList } from '../../text/utf8/module.f.mjs' import { codePointListToString } from '../../text/utf16/module.f.mjs' import { reverse } from '../../types/list/module.f.mjs' import { length } from '../../types/bit_vec/module.f.mjs' -import { error as resultError } from '../../types/result/module.f.mjs' -import { do_ } from '../module.f.mjs' +import { error as resultError, ok as resultOk, unwrap } from '../../types/result/module.f.mjs' +import { do_, pure } from '../module.f.mjs' import { mapStep as ioMapStep, pureError, pureOk, resultMapStep, resultStep, step as ioStep, } from '../module.f.mjs' -import { errorMessage, ioError } from '../common/module.f.mjs' /** - * The host-independent operations, re-exported so a caller that already names - * this module for `readFile` keeps naming it for `sandbox` and `all` too. They - * are defined in [`../common/module.f.mjs`](../common/module.f.mjs), which the - * browser runner links without reaching a Node type. + * Builds a normalized host error. The constructor exists so the shape is + * written once: every runner reports its failures through it, and a consumer + * matching on `'ioError'` knows what the payload holds. + * + * @type {(info: IoErrorInfo) => IoError} */ -export { - all, allOk, awaitIfPromise, both, errorMessage, errorSummary, fetch, import_, - ioError, isNotFound, now, sandbox, toIoError, -} from '../common/module.f.mjs' +export const ioError = info => ['ioError', info] /** * The host a {@link Listen} refuses. @@ -91,6 +83,46 @@ export const emptyHostError = ioError({ message: emptyHostMessage, }) +/** + * Normalizes a **thrown** value into an {@link IoError}: the OS error code when + * the host attached a string one, and a message that is the `Error`'s own or + * the value's string form. + * + * This is the boundary where an impure runner's `catch` becomes ordinary effect + * data. Nothing past it sees the thrown object, which is the point — a stack, a + * `cause`, and arbitrary own properties do not survive a wire hop, and a + * program that branched on them would be reading the host's implementation + * rather than the operation's contract. + * + * @type {(e: unknown) => IoError} + */ +export const toIoError = e => { + const message = e instanceof Error ? e.message : String(e) + if (typeof e !== 'object' || e === null || !('code' in e) || typeof e.code !== 'string') { + return ioError({ message }) + } + return ioError({ code: e.code, message }) +} + +/** + * True if `e` is a "file or directory does not exist" (`ENOENT`) error. + * + * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which + * {@link toIoError} keeps; the virtual interpreter reports the same code for + * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh + * store) while propagating genuine failures (permissions, corruption) rather + * than masking them. + * + * A {@link NotImplemented} is never "not found": a runner that cannot perform + * the operation has not looked for the path at all, so the two must not + * collapse into one benign branch — which is exactly what a bare `unknown` + * error channel used to allow. + * + * @type {(e: IoChannel) => boolean} + */ +export const isNotFound = ([tag, payload]) => + tag === 'ioError' && payload.code === 'ENOENT' + /** * `NodeOp`'s commands as data, so a runner that implements only part of them * can still tell an operation it lacks from a `Do` node whose `command` was @@ -123,6 +155,75 @@ const nodeCommandSet = { */ export const nodeCommands = /** @type {Commands} */ (Object.keys(nodeCommandSet)) +// all + +/** + * To run the operation `O` should be known by the runner/engine. + * This is the reason why we merge `O` with `All` in the resulting effect. + */ +export const all = + // `Func` cannot express a variadic generic operation, so the declared type + // is written out here and `do_`'s is set aside. + /** @type {(...a: readonly Effect[]) => Effect[], NotImplemented>} */ + (/** @type {unknown} */ (do_('all'))) + +/** + * Collapses a list of results into a result of the list, keeping the **first** + * error in list order and discarding the later ones. + * + * Keeping one is what makes this a `Result` rather than a report: the callers + * that need it are chains, and a chain has one error channel. A site that wants + * every failure wants a different return type and should not reach for this. + * + * @type {(list: readonly Result[]) => Result} + */ +const okList = list => { + for (const r of list) { + if (r[0] === 'error') { return r } + } + return resultOk(list.map(unwrap)) +} + +/** + * {@link all} in the `ok` channel: collects the values when every effect + * succeeded, and answers with the first failure otherwise. + * + * `all` alone cannot serve a fallible chain. Its envelope is the runner's + * (`OpResult`, saying whether the *operation* could be dispatched), so handing + * it `Effect`s nests one `Result` inside another and the caller receives + * `readonly Result[]`. That has to be collapsed before the chain can + * `step` again, and a continuation that forgets to is the value-discarding + * hazard this migration exists to remove — one level in, where it is harder to + * see. + * + * **Every effect still runs.** The short-circuit is in the *result*, not in the + * execution: `all` performs them concurrently and this reads the answers once + * they are all in, so a failure does not cancel its siblings the way it stops + * the sequential `forEachStep` in `./module.f.mjs`. The error channel + * unions the runner's + * `NotImplemented` with the effects' own `E` for the same reason every other + * step does — either can be what went wrong. + * + * @type {(...a: readonly Effect[]) => Effect} + */ +export const allOk = (...a) => + ioStep(all(...a), rs => pure(okList(rs))) + +/** + * @template {Operation} O0 + * @template T0 + * @template E0 + * @param {Effect} a + * @returns {(b: Effect) => Effect, Result], NotImplemented>} + */ +export const both = a => b => + /** @type {any} */ (all)(a, b) + +// fetch + +/** @type {Func} */ +export const fetch = do_('fetch') + // mkdir /** @type {Func} */ @@ -255,6 +356,11 @@ export const listen = do_('listen') /** @type {Func} */ export const forever = do_('forever') +// import + +/** @type {Func} */ +export const import_ = do_('import') + // write /** Emits a `Write` effect to the given named stream. */ @@ -324,6 +430,42 @@ export const readLine = stream => { return loop(null) } +// now + +/** @type {Func} */ +export const now = do_('now') + +// sandbox + +/** + * Runs a plain synchronous function in an isolated, measured environment. + * + * Combines try/catch and high-resolution timing into a single atomic operation. + * Only plain synchronous functions are accepted — no effects, no promises. + * + * Using a single operation rather than separate `TryCatch` + `Perf` effects is + * necessary for correctness: effects execute as async tasks, so the scheduler + * can insert arbitrary work between two separate timing calls, making the + * measured delta inaccurate. Here the clock reads happen synchronously around + * the function call with nothing in between. + * + * Future parameters (time limit, memory limit) can be added to the payload + * without breaking the API. Worker-based implementations can enforce hard + * limits via worker termination. + * + * @see {@link SandboxResult} + * + * @type {Func} + */ +export const sandbox = do_('sandbox') + +/** @type {Func} */ +const awaitPromise = do_('await') + +/** @type {(p: unknown) => Effect} */ +export const awaitIfPromise = p => + ioMapStep(awaitPromise(p), ([x]) => x) + // Test registration /** @type {Func} */ @@ -370,6 +512,38 @@ export const errorExit = s => */ export const exitCode = ([, code]) => code +/** + * Renders a channel error as a human line: an {@link IoError}'s own message, or + * the command name a runner could not dispatch. + * + * @type {(e: IoChannel) => string} + */ +export const errorMessage = ([tag, payload]) => + tag === 'notImplemented' ? `operation not implemented: ${payload}` : payload.message + +/** + * Renders a channel error for a **remote** caller: the command name for a + * {@link NotImplemented}, the OS error code for an `IoError`, and nothing else. + * + * {@link errorMessage} is for the operator of the program, who is entitled to + * the host's own words — including the path that failed. A protocol client is + * not, and the difference is not stylistic: `payload.message` is where the + * host puts the absolute path it could not read, so answering an MCP tool call + * with it publishes the server's filesystem layout to whoever is on the other + * end. The code (`ENOENT`, `EACCES`) says *what* went wrong without saying + * *where*, which is the part a client can act on anyway. + * + * A host that attached no code leaves nothing safe to forward, so the answer is + * the bare kind. That is deliberate: guessing which part of a free-text message + * is path-free is exactly the mistake this exists to prevent. + * + * @type {(e: IoChannel) => string} + */ +export const errorSummary = ([tag, payload]) => + tag === 'notImplemented' + ? `operation not implemented: ${payload}` + : payload.code === undefined ? 'io error' : `io error: ${payload.code}` + /** * Ends a program with an exit code that reflects `e`: `ok` yields `0`, and a * failure is reported on `stderr` and yields `1` ({@link errorExit}). diff --git a/fjs/effects/node/module.mjs b/fjs/effects/node/module.mjs index 6386bae9b..b523f9b3f 100644 --- a/fjs/effects/node/module.mjs +++ b/fjs/effects/node/module.mjs @@ -13,7 +13,7 @@ * @module * * @import { Effect } from '../types.ts' - * @import { Server as EffectServer, Headers, Module, NodeOp, RequestListener as Erl, NodeProgram, NodeProgramOptions, WriteConsoles, TestContext, TestFn, } from './types.ts' + * @import { IoResult, Server as EffectServer, Headers, Module, NodeOp, RequestListener as Erl, NodeProgram, NodeProgramOptions, WriteConsoles, TestContext, TestFn, } from './types.ts' * @import { Result } from '../../types/result/types.ts' * @import { StringMap } from '../../types/object/types.ts' * @import { Nullable } from '../../types/nullable/types.ts' @@ -30,7 +30,6 @@ import * as testContext from 'node:test' import { concat, normalize, toPosix } from '../../path/module.f.mjs' import { asyncRun } from '../module.mjs' -import { awaitPromise, io, sandbox } from '../common/module.mjs' import { memoryOperationMap } from './memory/module.mjs' import { emptyHost, emptyHostCode, emptyHostMessage, exitCode, toIoError, usesInlineTestContext, @@ -86,6 +85,22 @@ const createServer = http.createServer /** @typedef {(effect: Effect) => Promise>} _EffectToPromise */ +/** + * Performs host IO, reporting a thrown failure as an {@link IoResult} error. + * + * Every filesystem, network, and subprocess handler below goes through it, so + * the `catch` that turns an exception into effect data — and the normalization + * that keeps the channel serializable — happens in exactly one place. + * + * @template T + * @param {() => Promise} f + * @returns {Promise>} + */ +const io = async f => { + const r = await asyncTryCatch(f) + return r[0] === 'ok' ? r : error(toIoError(r[1])) +} + /** * Reads a request body, giving up at the `Vec` cap rather than at the point * where converting it would throw. @@ -231,6 +246,35 @@ const asyncImport = v => { return import(s1) } +/** + * @template T + * @param {() => T} f + * @returns {Promise<{ readonly result: Result, readonly duration: number }>} + */ +const sandbox = async f => { + /** @type {Result} */ + let result + let after + const before = performance.now() + try { + let p = f() + after = performance.now() + if (p instanceof Promise) { + p = await p + after = performance.now() + } + result = ok(p) + } catch (e) { + after = performance.now() + result = error(e) + } + return { result, duration: after - before } +} + +/** @type {(p: unknown) => Promise} */ +const awaitPromise = async p => + [p instanceof Promise ? await p : p] + const { now } = Date /** Maps `WriteConsoles` names to the corresponding Node.js writable streams. diff --git a/fjs/effects/node/proof.f.mjs b/fjs/effects/node/proof.f.mjs index 6c31b7f19..e04035da3 100644 --- a/fjs/effects/node/proof.f.mjs +++ b/fjs/effects/node/proof.f.mjs @@ -10,7 +10,7 @@ import { empty, isVec, uint, vec, vec8 } from "../../types/bit_vec/module.f.mjs" import { utf8, utf8ToString } from "../../text/module.f.mjs" import { match } from "../module.f.mjs" import { mapStep, step as ioStep } from "../module.f.mjs" -import { both, exitStep, fetch, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" +import { both, errorMessage, errorSummary, exitStep, fetch, ioError, isNotFound, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, toIoError, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" import { create as memCreate, read as memRead, write as memWrite } from "../memory/module.f.mjs" import { empty as listEmpty, nonEmpty as listNonEmpty } from "../list/module.f.mjs" import { emptyState, virtual } from "./virtual/module.f.mjs" @@ -50,8 +50,77 @@ const assertOk = (r, expected) => { } export const proof = { - // `toIoError`, `isNotFound`, `errorMessage` and `errorSummary` are proved - // in `../common/proof.f.mjs`, beside the module that now defines them. + // The one boundary where a runner's `catch` becomes effect data: whatever + // was thrown is reduced to a code (when the host attached a string one) + // and a message. + toIoError: { + error: () => { + assertIoMessage(toIoError(new Error('boom')), 'boom') + }, + withCode: () => { + const e = toIoError(Object.assign(new Error('missing'), { code: 'ENOENT' })) + assert(e[0] === 'ioError', e) + assertEq(e[1].code, 'ENOENT', e) + assertEq(e[1].message, 'missing', e) + }, + // A thrown non-`Error` still normalizes: the value's string form is the + // message, and there is no code to carry. + string: () => { + const e = toIoError('plain') + assert(e[0] === 'ioError', e) + assertEq(e[1].code, undefined, e) + assertEq(e[1].message, 'plain', e) + }, + null: () => { + assertIoMessage(toIoError(null), 'null') + }, + // An object whose `code` is not a string is not an OS error code, so it + // is dropped rather than carried as one. + nonStringCode: () => { + const e = toIoError({ code: 42 }) + assert(e[0] === 'ioError', e) + assertEq(e[1].code, undefined, e) + }, + noCode: () => { + const e = toIoError({}) + assert(e[0] === 'ioError', e) + assertEq(e[1].code, undefined, e) + }, + }, + isNotFound: { + enoent: () => { + assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) + }, + otherCode: () => { + assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) + }, + // A runner that cannot perform the operation has not looked for the + // path at all, so a missing handler is never "not found". + notImplemented: () => { + assert(!isNotFound(['notImplemented', 'readFile'])) + }, + }, + errorMessage: { + io: () => { + assertEq(errorMessage(ioError({ message: 'disk full' })), 'disk full') + }, + notImplemented: () => { + assertEq(errorMessage(['notImplemented', 'readFile']), 'operation not implemented: readFile') + }, + }, + errorSummary: { + // The distinction that matters: `errorMessage` hands back the host's + // words, which is where the path lives; `errorSummary` never does. + io: () => { + assertEq(errorSummary(ioError({ code: 'ENOENT', message: "no such file or directory, scandir '/home/u/.cas'" })), 'io error: ENOENT') + }, + ioWithoutCode: () => { + assertEq(errorSummary(ioError({ message: "cannot read '/home/u/.cas'" })), 'io error') + }, + notImplemented: () => { + assertEq(errorSummary(['notImplemented', 'readdir']), 'operation not implemented: readdir') + }, + }, exitStep: { // The exit-code policy a `NodeProgram` ends with: success is `0`... ok: () => { diff --git a/fjs/effects/node/types.ts b/fjs/effects/node/types.ts index 459c745d8..886a045f3 100644 --- a/fjs/effects/node/types.ts +++ b/fjs/effects/node/types.ts @@ -6,23 +6,86 @@ import type { List as EffectList } from '../../types/list/types.ts' import type { Vec } from '../../types/bit_vec/types.ts' -import type { All, Await, CommonOp, IoChannel, IoResult, OpResult } from '../common/types.ts' import type { MemOp } from '../memory/types.ts' import type { Nominal } from '../../types/nominal/types.ts' +import type { Result } from '../../types/result/types.ts' import type { StringMap } from '../../types/object/types.ts' -import type { Effect, Operation, ToAsyncOperationMap } from '../types.ts' +import type { Effect, NotImplemented, Operation, ToAsyncOperationMap } from '../types.ts' import type { List } from '../list/types.ts' /** - * The operations no host owns, re-exported so a consumer that already names - * this module for `ReadFile` keeps naming it for `Sandbox` and `All` too. They - * are declared in [`../common/types.ts`](../common/types.ts), which the browser - * runner reads without reaching a Node type. + * A host failure, normalized: whatever the runtime threw reduced to a + * serializable record. `code` is the OS error code when the host supplied one + * (`'ENOENT'`, `'EEXIST'`), absent otherwise. + * + * It is a tagged tuple for the same reason {@link NotImplemented} is — the two + * share an error channel, and the tag is what tells them apart. That + * distinction is the whole reason this type exists: with a bare `unknown` + * error, `NotImplemented | unknown` collapses to `unknown` and a program can no + * longer tell "this runner cannot do it" from "the host tried and failed". + * + * Normalizing also keeps the channel serializable. A thrown `Error` carries a + * stack, a `cause`, and arbitrary own properties; none of it survives a wire + * hop, and a runner in another process could not reproduce it. + */ +export type IoError = readonly['ioError', IoErrorInfo] + +export type IoErrorInfo = { + readonly code?: string + readonly message: string +} + +/** + * The result of an operation with no failures of its own: it either produces + * its value or reports that the runner does not implement it. + * + * Every operation's return type is a `Result`, including the ones that cannot + * fail on their own terms — an operation left on a raw contract would be a hole + * in the error channel, and a runner may omit a handler for any of them. + */ +export type OpResult = Result + +/** + * The error channel of anything that performs host IO: a normalized host + * failure, or the report that the runner does not implement the operation. + * + * It is one name rather than a union spelled at each site, and that is a + * migration property rather than brevity. An effect that does no IO *yet* is + * one added `readFile` away from doing some, and if each signature names its + * own errors, that one change walks up every enclosing signature — the failure + * mode that sank `throws` clauses elsewhere, where engineers eventually + * declared everything throwing rather than maintain the cascade. Declaring the + * standard channel once is that concession made deliberately: an IO-touching + * effect says it fails *the way node IO fails*, and gaining a new way to do so + * changes nothing above it. + * + * It is not a licence to widen. An operation with failures of its own extends + * the channel (`IoChannel | ParseError`), and a computation whose errors are + * genuinely narrower should say so — this is the default for IO, not a ceiling. + */ +export type IoChannel = NotImplemented | IoError + +/** + * The result of an operation that performs host IO: its value, a normalized + * host failure, or the missing-handler report. + */ +export type IoResult = Result + +// all + +/** + * Runs its effects concurrently and answers each one's whole `Result`. + * + * The nesting is deliberate and belongs to the runner: this envelope says + * whether `all` itself could be dispatched, and each inner `Result` is what + * that effect answered. `allOk` (`./module.f.mjs`) is the collapse a fallible + * chain wants. */ -export type { - All, Await, Fetch, Import, IoChannel, IoError, IoErrorInfo, IoResult, Module, - Now, OpResult, Sandbox, SandboxResult, -} from '../common/types.ts' +export type All = ['all', (...effects: Effect[]) => OpResult[]>] + +// fetch + +export type Fetch = ['fetch', (url: string) => IoResult] // mkdir @@ -198,6 +261,12 @@ export type Http = CreateServer | Listen export type Forever = ['forever', () => OpResult] +// import + +export type Module = StringMap + +export type Import = ['import', (path: string) => IoResult] + // write /** Named output streams accepted by the `Write` effect. */ @@ -230,6 +299,40 @@ export type Read = readonly['read', (stream: ReadConsoles) => OpResult +// now + +export type Now = readonly['now', () => OpResult] + +// sandbox + +/** + * The outcome of a `Sandbox` operation. + * + * `result` carries either `['ok', value]` or `['error', thrown]`. `duration` + * is a floating-point millisecond count with up to microsecond precision, + * matching `performance.now()` directly. Additional fields (allocated memory, + * max stack depth, coverage) may be added in future without breaking consumers. + */ +export type SandboxResult = { + readonly result: Result + /** + * Elapsed time in milliseconds (microsecond precision via `performance.now()`). + * The virtual runner returns `0` for deterministic tests. + */ + readonly duration: number +} + +export type Sandbox = readonly['sandbox', (f: () => T) => OpResult>] + +/** + * Resolves the return value of a test function inside the effect runner. + * If `p` is a real `Promise`, it is awaited and rejections propagate as + * throws. If `p` is any other value it is returned as-is. Plain thenables + * (objects with a `.then` method that are not `instanceof Promise`) are + * treated as ordinary values — not awaited. See `fjs/dev/tf/README.md`. + */ +export type Await = readonly['await', (p: unknown) => OpResult] + // Test registration /** @@ -268,13 +371,18 @@ export type Test = export type NodeOp = | Access - | CommonOp + | All + | Await + | Fetch | Fs | Http | Forever + | Import | MemOp + | Now | RandomInt | Read + | Sandbox | Write | Test diff --git a/fjs/emergent_testing/README.md b/fjs/emergent_testing/README.md index 703a1c99d..883600e00 100644 --- a/fjs/emergent_testing/README.md +++ b/fjs/emergent_testing/README.md @@ -83,47 +83,9 @@ Then invoke the runner: - `bun test` - `deno test --allow-read --allow-env --allow-sys` -### The browser - -[`browser/module.mjs`](./browser/module.mjs) runs the same proofs inside a -browser realm and answers a serializable report. The generated website hosts it; -see [`todo/browser-testing.md`](./todo/browser-testing.md) for the automated -runners still to come. - You can also implement your own runner, as long as it follows the proof-tree conventions described below. -## Design: one runner, several hosts - -`fjs t` and the browser runner are **the same runner**. Discovering -zero-argument leaves, walking the tree a proof returns, the structural `throw` -expectation, resolving real promises, formatting paths and counting results all -live once, in [`module.f.mjs`](./module.f.mjs); a host supplies only two things. - -- **A `Reporter`.** It receives semantic events — one normalized `TestResult` - per leaf, and the totals — and decides how they are shown. `defaultReporter` - writes coloured lines (or GitHub annotations); `recordingReporter` hands each - result to the `report` operation, and the browser adapter renders it into the - page. A `TestResult` carries no terminal text and no DOM, so neither reporter - can smuggle presentation back into the core. -- **An effect runner.** `sandbox` is the one operation that actually *executes* - a proof body, and each host implements it against its own realm — Node in - [`../effects/node/module.mjs`](../effects/node/module.mjs), the browser in - [`../effects/browser/module.mjs`](../effects/browser/module.mjs). Both - implement it identically, because a suite that meant different things in the - two would not be one suite. - -The two runners *used* to be two implementations of the same rules, in -`module.f.mjs` and a standalone `browser.mjs`, and the rules had begun to drift. -Consult that history before adding a rule to either host: it belongs in the -core, or it is not a rule about proofs. - -External runners (`node --test`, `bun test`, `deno test`) are the one genuine -exception, and `registerModule` is why: those frameworks own scheduling and -counting, so they are handed the tree rather than driven through it. The -differences that follow from that are documented in -[`todo/661-test-runner-behavior.md`](./todo/661-test-runner-behavior.md). - ## Design: dependency-free proofs Unlike most test frameworks (Jest, Mocha, Vitest, …), a proof does **not** import @@ -270,12 +232,6 @@ to decide whether to await it. Only genuine `Promise` instances are awaited; plain *thenables* — objects with a `.then` method that are not `instanceof Promise` — are treated as ordinary return values and walked as sub-trees. -Every runner asks the same question, in the same place — the `sandbox` -operation — so a suite means the same thing under `fjs t` and in a browser. One -consequence is that a promise built in *another* realm is not `instanceof -Promise` and so is not awaited; see -[`todo/hostile-proof-values.md`](./todo/hostile-proof-values.md). - This is intentional. FunctionalScript does not allow direct `Promise` construction; `Promise` objects only arise as the return value of `async` functions (an Effect). A plain `{ then: f }` object in FunctionalScript is almost diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs new file mode 100644 index 000000000..3d280f3bf --- /dev/null +++ b/fjs/emergent_testing/browser.mjs @@ -0,0 +1,455 @@ +/** + * Browser-native proof execution and report rendering. + * + * The module deliberately has no Node dependencies: generated applications + * import it directly as an ES module in the browser. + * Proof failures resolve the published report with `status: 'failed'`; an + * automated outer controller is responsible for consuming that status and + * choosing a nonzero process exit code. + * + * Every DOM entry point reaches the page through the `root` element it is + * given — `root.ownerDocument` and its `defaultView` — never through the + * runner realm's own `window`/`document`. A page embedding the suite in an + * iframe therefore renders into that frame, and a proof can drive the module + * with a stand-in root. + * + * @module + * + * @import { _TestAndPath } from './types.ts' + */ + +import { collectTests, fmtPath } from './module.f.mjs' + +/** @type {(value: unknown) => string} */ +const text = value => { + try { + return String(value) + } catch { + return 'Unknown thrown value' + } +} + +/** + * The message and stack to report a thrown value by. + * + * An Error thrown from another realm — an iframe, a worker — is not + * `instanceof Error` here, and its stack is the very thing the report exists to + * carry. What the fields say is therefore the test, not where the value was + * made: anything carrying `message` or `stack` is read as the failure it + * describes, and everything else by its own text. + * + * @type {(error: unknown) => readonly [string, string]} + */ +const errorDetails = error => { + try { + if (error !== null && (typeof error === 'object' || typeof error === 'function') + && ('message' in error || 'stack' in error)) { + const { message, stack } = /** @type {{ readonly message?: unknown, readonly stack?: unknown }} */ (error) + const described = text(message) + return [described, stack === undefined ? described : text(stack)] + } + } catch { + // Reading the fields, and asking whether they are there at all, are + // user-observable operations: revoked proxies and accessors can throw + // while the failure is inspected. + } + const fallback = text(error) + return [fallback, fallback] +} + +/** @typedef {{ readonly module: string, readonly path: string, readonly status: string, readonly duration: number, readonly message?: string, readonly stack?: string }} _BrowserTestResult */ +/** @typedef {{ readonly status: string, readonly browser: string, readonly totals: { readonly tests: number, readonly passed: number, readonly failed: number }, readonly duration: number, readonly results: readonly _BrowserTestResult[] }} BrowserTestReport */ + +/** + * Attaches the handlers with the intrinsic `then`, but answers with a promise + * of this realm instead of the one `then` returns. That result is built by + * `constructor[Symbol.species]`, which a promise can make an ordinary object: + * awaiting it would end the test before the promise it came from ever settled + * and put the species object itself in the report. + * + * The `then` call still throws — before either handler is attached — for a + * value that is not a promise or whose species construction fails, which is + * what `runPromise` reads. + * + * @type {(value: unknown, fulfilled: (value: unknown) => Promise | readonly _BrowserTestResult[], rejected: (error: unknown) => readonly _BrowserTestResult[]) => Promise} + */ +const subscribe = (value, fulfilled, rejected) => { + /** @type {(results: Promise | readonly _BrowserTestResult[]) => void} */ + let settle = () => undefined + /** @type {Promise} */ + const settled = new Promise(resolve => { settle = resolve }) + Reflect.apply(Promise.prototype.then, value, [ + /** @type {(value: unknown) => void} */ (resolved => settle(fulfilled(resolved))), + /** @type {(error: unknown) => void} */ (error => settle(rejected(error))), + ]) + return settled +} + +/** + * Reproduces the lookup `then` performs before it builds its result promise: + * `constructor`, then its `Symbol.species`. A genuine promise with a hostile + * species throws here too; an object that only claims to be a promise failed + * the brand check first and reads its `constructor` cleanly. That is what + * separates a promise nothing can subscribe to from an ordinary proof tree, + * once shadowing `constructor` has turned out to be impossible. + * + * @type {(value: unknown) => boolean} + */ +const speciesFails = value => { + try { + if (value === null || value === undefined) { return false } + const { constructor } = /** @type {{ readonly constructor?: unknown }} */ (value) + if (constructor === null || constructor === undefined) { return false } + // The species itself never matters, only whether reading it completes: + // that is the step `then` takes before it builds its result. + void /** @type {{ readonly [Symbol.species]?: unknown }} */ (constructor)[Symbol.species] + return false + } catch { + return true + } +} + +/** + * Runs the intrinsic Promise `then` only for genuine promises. The first call + * is both the native brand check and the normal await path, so arbitrary proof + * objects with a `then` key are never assimilated. + * + * A genuine Promise can still throw after passing the brand check if species + * construction fails. In that case, temporarily shadow `constructor` with the + * current realm's Promise and retry the same intrinsic call; the shadow is + * removed immediately after the handlers are attached. + * + * A promise that pins its own `constructor`, or is frozen, leaves nothing to + * shadow, so no subscription is possible at all. The species failure is then + * reported against the test that produced the promise — the same outcome + * `await` gives it in the Node runner — because a result nobody can observe is + * not a pass. A non-extensible object that merely claims to be a promise + * reaches the same dead end and is still walked as the proof tree it is. + * + * @type {(value: unknown, fulfilled: (value: unknown) => Promise | readonly _BrowserTestResult[], rejected: (error: unknown) => readonly _BrowserTestResult[]) => Promise | null} + */ +const runPromise = (value, fulfilled, rejected) => { + const call = () => subscribe(value, fulfilled, rejected) + try { + return call() + } catch (error) { + // Either `value` is not a promise and the brand check rejected it + // before any handler was attached, or it is a genuine promise that + // failed while constructing the result through Symbol.species. Only + // the second case is worth a retry, and `then` attaches nothing before + // it throws, so the retry cannot run the handlers twice. + try { + if (Object.prototype.toString.call(value) !== '[object Promise]') { return null } + } catch { + return null + } + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) { return null } + /** @type {PropertyDescriptor | undefined} */ + let descriptor + try { + descriptor = Object.getOwnPropertyDescriptor(value, 'constructor') + Object.defineProperty(value, 'constructor', { value: Promise, configurable: true }) + } catch { + // Nothing to shadow, so the value is whatever its own lookup says: + // a promise that cannot be subscribed to fails on the species error + // rather than passing on a result that was never awaited, and a + // frozen spoof is an ordinary proof tree. + return speciesFails(value) ? Promise.resolve(rejected(error)) : null + } + try { + return call() + } catch { + // The intrinsic `constructor` cannot fail the retry, so the brand + // check did: `value` only claims to be a promise and is walked as + // an ordinary proof result. + return null + } finally { + try { + if (descriptor === undefined) { + Reflect.deleteProperty(value, 'constructor') + } else { + Object.defineProperty(value, 'constructor', descriptor) + } + } catch { + // The temporary property is configurable, so ordinary objects + // restore cleanly. A hostile Proxy can make restoration itself + // observable. + } + } + } +} + +/** @type {(module: string, path: readonly (string | null)[], throws: boolean, fn: () => unknown, result: (result: _BrowserTestResult) => void) => Promise} */ +const runOne = (module, path, throws, fn, result) => { + const start = performance.now() + /** @type {(value: unknown) => Promise | readonly _BrowserTestResult[]} */ + const passed = value => { + const duration = performance.now() - start + if (throws) { + const failure = { module, path: fmtPath(path), status: 'failed', duration, + message: 'Expected the proof to throw', stack: '' } + result(failure) + return [failure] + } + // Reading the returned tree runs user code: an enumerable getter + // or a proxy trap can throw. That is a failure of the test that + // produced the value, never of the run — a rejected run leaves the + // page in `running` with no report and no completion event. + /** @type {readonly _TestAndPath[]} */ + let children + try { + children = collectTests([...path, null], false, value) + } catch (error) { + return failed(error) + } + return Promise.all(children.map(([childPath, child]) => + runOne(module, childPath, child.throws, child.fn, result) + )).then(results => { + const success = { module, path: fmtPath(path), status: 'passed', duration } + result(success) + return [success, ...results.flat()] + }) + } + /** @type {(error: unknown) => readonly _BrowserTestResult[]} */ + const failed = error => { + const duration = performance.now() - start + if (throws) { + const success = { module, path: fmtPath(path), status: 'passed', duration } + result(success) + return [success] + } + const [message, stack] = errorDetails(error) + const failure = { module, path: fmtPath(path), status: 'failed', duration, message, stack } + result(failure) + return [failure] + } + // Wrap the raw return so Promise resolution does not assimilate arbitrary + // objects with a `then` proof property. The Node runner awaits only actual + // promises, and browser execution must preserve that same test-tree rule. + return Promise.resolve().then(() => [fn()]).then( + ([value]) => runPromise(value, passed, failed) ?? passed(value), + failed + ) +} + +/** @type {(status: string, duration: number, results: readonly _BrowserTestResult[]) => BrowserTestReport} */ +const reportOf = (status, duration, results) => { + const failed = results.filter(result => result.status === 'failed').length + return { + status, + browser: navigator.userAgent, + totals: { tests: results.length, passed: results.length - failed, failed }, + duration, + results, + } +} + +/** + * Runs named proof exports and returns the serializable browser report. + * + * @type {(modules: readonly (readonly [string, unknown])[], result?: (result: _BrowserTestResult) => void) => Promise} + */ +export const runBrowserProofs = (modules, result = () => undefined) => { + const start = performance.now() + // Reporting each result as it lands is the page's own code. A renderer that + // throws must not take the run down with it: the report it fails to show is + // the one thing the page is still waiting for. + /** @type {(result: _BrowserTestResult) => void} */ + const announce = value => { + try { + result(value) + } catch { + // The result stays in the report the run resolves with. + } + } + /** @type {(module: string, error: unknown) => () => Promise} */ + const unreadable = (module, error) => () => { + const [message, stack] = errorDetails(error) + const failure = { module, path: '', status: 'failed', duration: 0, message, stack } + announce(failure) + return Promise.resolve([failure]) + } + const tests = modules.flatMap(([module, proof]) => { + // Reading an exported tree runs user code just as reading a returned + // one does. A module that cannot be enumerated is one failed module, + // never a run that ends without a report. + try { + return collectTests([], false, proof).map(([path, entry]) => + () => runOne(module, path, entry.throws, entry.fn, announce) + ) + } catch (error) { + return [unreadable(module, error)] + } + }) + const batchSize = 25 + /** @type {(index: number, results: readonly _BrowserTestResult[]) => Promise} */ + const runBatch = (index, results) => { + const batch = tests.slice(index, index + batchSize) + if (batch.length === 0) { return Promise.resolve(results) } + return Promise.all(batch.map(test => test())).then(next => + new Promise(resolve => setTimeout(resolve, 0, [...results, ...next.flat()])) + ).then(next => runBatch(index + batchSize, next)) + } + const completed = runBatch(0, []) + return completed.then(results => reportOf( + results.some(result => result.status === 'failed') ? 'failed' : 'passed', + performance.now() - start, + results, + )) +} + +/** @typedef {(source: string) => Promise<{ readonly proof?: unknown }>} _BrowserImporter */ +/** @typedef {{ readonly status: 'loaded', readonly source: string, readonly proof: unknown } | { readonly status: 'error', readonly source: string, readonly error: unknown }} _LoadedModule */ +/** @typedef {Window & { fjsBrowserTestReport?: Promise }} _TestWindow */ + +/** @type {(root: Element) => _TestWindow | null} */ +const viewOf = root => root.ownerDocument.defaultView + +/** + * Renders the settled report into the page, publishes the run as + * `fjsBrowserTestReport` on the root's window, and announces it with + * `fjs-browser-test-complete`. + * + * @type {(root: Element, report: Promise) => Promise} + */ +const publish = (root, report) => { + const view = viewOf(root) + const done = report.then(value => { + renderBrowserReport(root, value) + view?.dispatchEvent(new CustomEvent('fjs-browser-test-complete', { detail: value })) + return value + }) + if (view !== null) { view.fjsBrowserTestReport = done } + return done +} + +/** + * Loads proof modules after the page has rendered, reporting module-loading + * progress before proof execution begins. + * + * @type {(root: Element, sources: readonly string[], importer: _BrowserImporter) => Promise} + */ +export const startBrowserTestSources = (root, sources, importer) => { + const start = performance.now() + setState(root, 'loading') + let loaded = 0 + const summary = root.querySelector('[data-test-summary]') + // Set synchronously, before any import settles: otherwise the page keeps + // showing its idle text throughout loading — indefinitely, if a module + // import never settles — even though the state and control already + // changed. + if (summary !== null) { summary.textContent = `Loading 0/${sources.length}` } + // The importer is supplied by the page, so obtaining the promise is itself + // a failure point: a synchronous throw becomes a rejection here and is + // reported as a loader failure, rather than escaping past a `loading` state + // that no report or completion event ever replaces. + /** @type {(source: string) => Promise<{ readonly proof?: unknown }>} */ + const load = source => { + try { + return importer(source) + } catch (error) { + return Promise.reject(error) + } + } + /** @type {Promise} */ + const modules = Promise.all(sources.map(source => load(source).then( + module => { + loaded += 1 + if (summary !== null) { summary.textContent = `Loading ${loaded}/${sources.length}: ${source}` } + return /** @type {const} */ ({ status: 'loaded', source, proof: module.proof }) + }, + error => /** @type {const} */ ({ status: 'error', source, error }) + ))) + const report = modules.then(loadedModules => { + const rejected = loadedModules.flatMap(module => + module.status === 'error' ? [module] : []) + if (rejected.length !== 0) { + // A module that never linked has no tests to run, so the run stops + // here. Each rejection is still counted as a failed result: totals + // that disagreed with `results` would tell an automated consumer + // the suite was empty rather than broken. + const duration = performance.now() - start + return publish(root, Promise.resolve(reportOf('infrastructure-error', duration, + rejected.map(({ source, error }) => { + const [message, stack] = errorDetails(error) + return { module: source, path: '', status: 'failed', duration, message, stack } + })))) + } + return startBrowserTests(root, loadedModules.flatMap(module => + module.status === 'loaded' + ? [/** @type {const} */ ([module.source, module.proof])] + : [])) + }) + const view = viewOf(root) + if (view !== null) { view.fjsBrowserTestReport = report } + return report +} + +/** + * Sets the runner state and keeps the `Run` control's real disabled state in + * sync with it: passive while a suite is loading or running, active in every + * other state (idle, or any terminal status). A disabled attribute is used + * rather than a click handler that silently ignores the action, so assistive + * technology sees the same unavailability a sighted user does. + * + * @type {(root: Element, state: string) => void} + */ +const setState = (root, state) => { + root.setAttribute('data-state', state) + const runButton = root.querySelector('[data-test-run]') + if (runButton !== null) { + if (state === 'loading' || state === 'running') { + runButton.setAttribute('disabled', '') + } else { + runButton.removeAttribute('disabled') + } + } +} + +/** + * Renders a completed report in the browser test page. + * + * @type {(root: Element, report: BrowserTestReport) => void} + */ +export const renderBrowserReport = (root, report) => { + setState(root, report.status) + const summary = root.querySelector('[data-test-summary]') + if (summary !== null) { + summary.textContent = report.status === 'infrastructure-error' + ? `Infrastructure error: ${report.totals.failed} failed to load (${report.duration.toFixed(1)} ms)` + : `${report.totals.passed} passed, ${report.totals.failed} failed (${report.duration.toFixed(1)} ms)` + } + const output = root.querySelector('[data-test-results]') + if (output !== null) { + output.replaceChildren(...report.results.map(result => + renderResult(root.ownerDocument, result))) + } +} + +/** @type {(document: Document, result: _BrowserTestResult) => HTMLLIElement} */ +const renderResult = (document, result) => { + const item = document.createElement('li') + item.setAttribute('data-status', result.status) + const detail = result.status === 'failed' ? `: ${result.message}\n${result.stack}` : '' + item.textContent = `${result.status === 'passed' ? 'PASS' : 'FAIL'} ${result.module} ${result.path} (${result.duration.toFixed(1)} ms)${detail}` + return item +} + +/** + * Runs the application, publishes its promise as `window.fjsBrowserTestReport`, + * and dispatches `fjs-browser-test-complete` with the report in `detail`. + * + * @type {(root: Element, modules: readonly (readonly [string, unknown])[]) => Promise} + */ +export const startBrowserTests = (root, modules) => { + setState(root, 'running') + const output = root.querySelector('[data-test-results]') + if (output !== null) { output.replaceChildren() } + let completed = 0 + return publish(root, runBrowserProofs(modules, result => { + completed += 1 + const summary = root.querySelector('[data-test-summary]') + if (summary !== null) { summary.textContent = `${completed} tests completed…` } + if (output !== null) { output.append(renderResult(root.ownerDocument, result)) } + })) +} diff --git a/fjs/emergent_testing/browser/module.f.mjs b/fjs/emergent_testing/browser/module.f.mjs deleted file mode 100644 index 45718d224..000000000 --- a/fjs/emergent_testing/browser/module.f.mjs +++ /dev/null @@ -1,140 +0,0 @@ -/** - * The browser proof application: link the proof modules, run them through the - * shared emergent-testing core, and answer one serializable report. - * - * **It performs no browser operation of its own.** Linking a module, reading - * the clock, executing a proof body and recording a result are all operations - * (`./types.ts`), so this program is exactly as runnable from a proof with a - * stand-in interpreter as it is from a page. What is genuinely the browser's — - * the DOM, the published promise, the completion event — lives in the impure - * adapter beside it, [`./module.mjs`](./module.mjs). - * - * **It owns no proof semantics either.** Discovering zero-argument leaves, - * walking a returned tree, the structural `throw` expectation, resolving real - * promises and counting results are `../module.f.mjs`'s, the same module `fjs t` - * runs through — this file only decides what a *run* is: load, run, report. - * - * @module - * - * @import { Effect } from '../../effects/types.ts' - * @import { Module } from '../../effects/common/types.ts' - * @import { IoChannel, Import } from '../../effects/common/types.ts' - * @import { TestResult } from '../types.ts' - * @import { BrowserOp, BrowserProgram, BrowserTestReport, ReportStatus, _Loaded } from './types.ts' - */ - -import { allOk, errorMessage, import_, now } from '../../effects/common/module.f.mjs' -import { history, historyStep, mapStep, pureOk, resultMapStep, step } from '../../effects/module.f.mjs' -import { recordingReporter, reported, runModuleMap } from '../module.f.mjs' -import { fromEntries } from '../../types/object/module.f.mjs' -import { ok } from '../../types/result/module.f.mjs' - -/** - * Builds the report from the results a run recorded. Totals are counted here - * rather than reported separately, so they cannot disagree with `results`. - * - * @type {(status: ReportStatus, browser: string, duration: number, results: readonly TestResult[]) => BrowserTestReport} - */ -export const reportOf = (status, browser, duration, results) => { - const failed = results.filter(result => result.status === 'failed').length - return { - status, - browser, - totals: { tests: results.length, passed: results.length - failed, failed }, - duration, - results, - } -} - -/** - * The result standing for something that went wrong outside any proof: a module - * that would not link, or an operation the runner does not implement. - * - * It is counted as a failed result rather than left out. Totals that disagreed - * with `results` would tell an automated consumer the suite was empty rather - * than broken. - * - * @type {(module: string, message: string) => TestResult} - */ -const infrastructureResult = (module, message) => - ({ module, path: '', status: 'failed', duration: 0, message, stack: '' }) - -/** - * Links one source, keeping the failure rather than propagating it: a run - * reports *every* module that would not link, and the first one would - * short-circuit the rest away. - * - * @type {(source: string) => Effect} - */ -const loadOne = source => resultMapStep(import_(source), r => { - /** @type {_Loaded} */ - const loaded = [source, r] - return ok(loaded) -}) - -/** @type {(results: readonly TestResult[]) => ReportStatus} */ -const statusOf = results => - results.some(result => result.status === 'failed') ? 'failed' : 'passed' - -/** @internal What a run answers before it is timed and packaged. */ -/** @typedef {readonly[ReportStatus, readonly TestResult[]]} _Outcome */ - -/** - * Runs the modules that linked, or reports the ones that did not. - * - * A module that never linked has no tests to run, so the run stops at the first - * broken graph rather than reporting a partial suite as a complete one. - * - * @type {(loaded: readonly _Loaded[]) => Effect} - */ -const runLoaded = loaded => { - const linked = loaded.flatMap(([source, r]) => - r[0] === 'ok' ? [/** @type {const} */ ([source, r[1]])] : []) - if (linked.length !== loaded.length) { - /** @type {_Outcome} */ - const broken = ['infrastructure-error', loaded.flatMap(([source, r]) => - r[0] === 'error' ? [infrastructureResult(source, errorMessage(r[1]))] : [])] - return pureOk(broken) - } - const ran = runModuleMap(recordingReporter)(fromEntries(linked)) - const collected = step(ran, () => reported()) - return mapStep(collected, results => { - /** @type {_Outcome} */ - const outcome = [statusOf(results), results] - return outcome - }) -} - -/** @type {(sources: readonly string[]) => Effect} */ -const runSources = sources => - step(allOk(...sources.map(loadOne)), runLoaded) - -/** - * A run that could not finish, reported as one infrastructure error against the - * run itself. - * - * This is what makes {@link BrowserProgram}'s empty error channel true: a - * runner that cannot dispatch `sandbox`, `now` or `report` leaves the program - * with nothing to answer, and a page waiting on the run has nowhere to put a - * failure it never receives. - * - * @type {(browser: string, message: string) => BrowserTestReport} - */ -const failedRun = (browser, message) => - reportOf('infrastructure-error', browser, 0, [infrastructureResult('', message)]) - -/** - * The application: link every source, run the proofs that linked, and answer - * the report. - * - * @type {BrowserProgram} - */ -export const main = ({ browser, sources }) => { - const started = history(now()) - const outcome = historyStep(started, () => runSources(sources)) - const ended = historyStep(outcome, () => now()) - const report = mapStep(ended, ([end, [status, results], start]) => - reportOf(status, browser, end - start, results)) - return resultMapStep(report, r => - ok(r[0] === 'error' ? failedRun(browser, errorMessage(r[1])) : r[1])) -} diff --git a/fjs/emergent_testing/browser/module.mjs b/fjs/emergent_testing/browser/module.mjs deleted file mode 100644 index d64f162ac..000000000 --- a/fjs/emergent_testing/browser/module.mjs +++ /dev/null @@ -1,211 +0,0 @@ -/** - * The browser host adapter: capabilities, DOM rendering, and publication. - * - * It owns nothing about what a proof *means*. Walking proof trees, the - * structural `throw` expectation, resolving real promises, path formatting and - * the totals belong to `../module.f.mjs` — the module `fjs t` runs through — - * and what a *run* is belongs to the pure application in - * [`./module.f.mjs`](./module.f.mjs). What is left here is the browser: an - * interpreter for the operations that application performs, the DOM it is - * rendered into, and the promise and event a controller reads it from. - * - * The module deliberately has no Node dependency: generated applications import - * it directly as an ES module in the browser. - * Proof failures resolve the published report with `status: 'failed'`; an - * automated outer controller is responsible for consuming that status and - * choosing a nonzero process exit code. - * - * Every DOM entry point reaches the page through the `root` element it is - * given — `root.ownerDocument` and its `defaultView` — never through the - * runner realm's own `window`/`document`. A page embedding the suite in an - * iframe therefore renders into that frame, and a proof can drive the module - * with a stand-in root. - * - * @module - * - * @import { Effect } from '../../effects/types.ts' - * @import { Result } from '../../types/result/types.ts' - * @import { BrowserImporter } from '../../effects/browser/module.mjs' - * @import { TestResult } from '../types.ts' - * @import { BrowserOp, BrowserTestReport } from './types.ts' - */ - -import { asyncRun } from '../../effects/module.mjs' -import { browserOperationMap } from '../../effects/browser/module.mjs' -import { errorDetails, fmtCall } from '../module.f.mjs' -import { main, reportOf } from './module.f.mjs' -import { ok } from '../../types/result/module.f.mjs' -import { tryCatch } from '../../types/result/module.mjs' - -/** @typedef {Window & { fjsBrowserTestReport?: Promise }} _TestWindow */ - -/** @typedef {(effect: Effect) => Promise>} _Run */ - -/** - * What a run is reported as when even *describing* its panic panicked. - * - * There is nothing left to say about the value at that point — every way of - * reading it is a way of being thrown by it — so the report says exactly that - * rather than inventing a message. - */ -const unreadableFailure = 'The run failed with a value that cannot be read' - -/** @type {(root: Element) => _TestWindow | null} */ -const viewOf = root => root.ownerDocument.defaultView - -/** - * Sets the runner state and keeps the `Run` control's real disabled state in - * sync with it: passive while a suite is loading or running, active in every - * other state (idle, or any terminal status). A disabled attribute is used - * rather than a click handler that silently ignores the action, so assistive - * technology sees the same unavailability a sighted user does. - * - * @type {(root: Element, state: string) => void} - */ -const setState = (root, state) => { - root.setAttribute('data-state', state) - const runButton = root.querySelector('[data-test-run]') - if (runButton !== null) { - if (state === 'loading' || state === 'running') { - runButton.setAttribute('disabled', '') - } else { - runButton.removeAttribute('disabled') - } - } -} - -/** @type {(document: Document, result: TestResult) => HTMLLIElement} */ -const renderResult = (document, result) => { - const item = document.createElement('li') - item.setAttribute('data-status', result.status) - const detail = result.status === 'failed' ? `: ${result.message}\n${result.stack}` : '' - // `fmtCall`, so a test is named here exactly as `fjs t` names it — one - // identifier, one spelling, whichever runner is reporting. - item.textContent = `${result.status === 'passed' ? 'PASS' : 'FAIL'} ${fmtCall(result.module, result.path)} (${result.duration.toFixed(1)} ms)${detail}` - return item -} - -/** - * Renders a completed report in the browser test page. - * - * @type {(root: Element, report: BrowserTestReport) => void} - */ -export const renderBrowserReport = (root, report) => { - setState(root, report.status) - const summary = root.querySelector('[data-test-summary]') - if (summary !== null) { - summary.textContent = report.status === 'infrastructure-error' - // Not "failed to load": this status also covers a run that panicked - // and a runner missing an operation, and naming the wrong cause - // sends a reader to debug their imports. Each result below carries - // its own module and message, so the detail is not lost. - ? `Infrastructure error: ${report.totals.failed} failed (${report.duration.toFixed(1)} ms)` - : `${report.totals.passed} passed, ${report.totals.failed} failed (${report.duration.toFixed(1)} ms)` - } - const output = root.querySelector('[data-test-results]') - if (output !== null) { - output.replaceChildren(...report.results.map(result => - renderResult(root.ownerDocument, result))) - } -} - -/** - * Runs the browser application against `root`, publishes its promise as - * `fjsBrowserTestReport` on the root's window, and dispatches - * `fjs-browser-test-complete` with the report in `detail`. - * - * `importer` is the seam a controller reaches for: an application root resolves - * its own specifiers, and a proof drives the whole runner without a network. - * The default is the realm's own dynamic `import`. - * - * @type {(root: Element, sources: readonly string[], importer?: BrowserImporter) => Promise} - */ -export const startBrowserTestSources = (root, sources, importer = source => import(source)) => { - setState(root, 'loading') - const summary = root.querySelector('[data-test-summary]') - const output = root.querySelector('[data-test-results]') - if (output !== null) { output.replaceChildren() } - // Set synchronously, before any import settles: otherwise the page keeps - // showing its idle text throughout loading — indefinitely, if a module - // import never settles — even though the state and control already changed. - if (summary !== null) { summary.textContent = `Loading 0/${sources.length}` } - let loaded = 0 - /** @type {(source: string) => void} */ - const linked = source => { - loaded += 1 - if (summary !== null) { summary.textContent = `Loading ${loaded}/${sources.length}: ${source}` } - // Whether the module linked or not, the loading phase is over once the - // last answer is in — a broken graph is reported by the run, not by - // leaving the page in `loading` forever. - if (loaded === sources.length) { setState(root, 'running') } - } - /** @type {BrowserImporter} */ - const load = source => importer(source).then( - module => { linked(source); return module }, - error => { linked(source); throw error }) - /** @type {readonly TestResult[]} */ - let results = [] - /** @type {_Run} */ - const run = asyncRun({ - ...browserOperationMap(effect => run(effect), load), - report: async result => { - results = [...results, result] - // Showing a result as it lands is the page's own rendering, and it - // must not take the run down with it: the report is the one thing - // the page is still waiting for, and it is already recorded above. - try { - if (summary !== null) { summary.textContent = `${results.length} tests completed…` } - if (output !== null) { output.append(renderResult(root.ownerDocument, result)) } - } catch { - // The result stays in the report the run resolves with. - } - return ok(undefined) - }, - reported: async () => ok(results), - }) - const view = viewOf(root) - const browser = navigatorName(root) - // The application's error channel is empty — every failure it can *answer* - // is reported — so the run's `Result` is always `ok`. A **panic** is the - // other thing, and it is what `never` cannot promise away: reading a proof - // tree runs user code, so an enumerable getter or a proxy trap throws - // through the shared traversal, which has no `try`/`catch` to give it. That - // must not be where the page stops. A rejected run with the suite left in - // `running` is the one outcome an automated controller cannot act on, so - // the panic becomes the report it could not produce — see - // `../todo/hostile-proof-values.md` for attributing it to the test that - // caused it. - const settled = run(main({ browser, sources })).then( - ([, value]) => value, - error => { - // Describing the panic reads the value that caused it, and the - // value is the reason there was one: a proxy whose traps throw - // *itself* makes `errorDetails` panic in turn. This is the last - // handler there is, so it is the one that may not fail — a second - // failure here is the page stuck in `running` again, with the - // guard that was supposed to prevent it. What it cannot describe, - // it says it cannot describe. - const described = tryCatch(() => errorDetails(error)) - const [message, stack] = described[0] === 'ok' - ? described[1] - : [unreadableFailure, ''] - return reportOf('infrastructure-error', browser, 0, [ - { module: '', path: '', status: 'failed', duration: 0, message, stack }]) - }) - const report = settled.then(value => { - renderBrowserReport(root, value) - view?.dispatchEvent(new CustomEvent('fjs-browser-test-complete', { detail: value })) - return value - }) - if (view !== null) { view.fjsBrowserTestReport = report } - return report -} - -/** - * The realm the run is recorded under, read through the root's own window so an - * embedded suite names the frame it actually runs in — and so a proof driving - * the runner with a stand-in root never needs a global `navigator`. - * - * @type {(root: Element) => string} - */ -const navigatorName = root => viewOf(root)?.navigator.userAgent ?? '' diff --git a/fjs/emergent_testing/browser/proof.f.mjs b/fjs/emergent_testing/browser/proof.f.mjs deleted file mode 100644 index e46c4cf31..000000000 --- a/fjs/emergent_testing/browser/proof.f.mjs +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Proofs for the browser proof application. - * - * The application performs only operations, so a state-threading stand-in - * interpreter is enough to drive every path from Node — no browser, no DOM, and - * no globals for these proofs to install and unset. `sandbox` is the same - * pass-through the virtual Node runner uses: a fixture returns the - * `SandboxResult` it wants reported, so outcomes are dictated rather than - * measured. - * - * @import { Result } from '../../types/result/types.ts' - * @import { MemOperationMap, RunInstance } from '../../effects/mock/types.ts' - * @import { Module, SandboxResult } from '../../effects/common/types.ts' - * @import { StringMap } from '../../types/object/types.ts' - * @import { TestResult } from '../types.ts' - * @import { BrowserOp, BrowserTestReport } from './types.ts' - */ - -import { assert, assertEq } from '../../asserts/module.f.mjs' -import { ioError } from '../../effects/common/module.f.mjs' -import { notImplemented } from '../../effects/module.f.mjs' -import { run as mockRun } from '../../effects/mock/module.f.mjs' -import { error, ok, unwrap } from '../../types/result/module.f.mjs' -import { main } from './module.f.mjs' - -/** - * @typedef {{ - * readonly time: number, - * readonly clock: boolean, - * readonly results: readonly TestResult[], - * readonly modules: StringMap, - * }} _State - */ - -/** @type {MemOperationMap} */ -const map = { - all: (...a) => state => { - /** @type {readonly Result[]} */ - let e = [] - for (const i of a) { - const [ns, ei] = browser(state)(i) - state = ns - e = [...e, ei] - } - return [state, ok(e)] - }, - await: p => state => [state, ok([p])], - fetch: () => state => [state, error(ioError({ message: 'no network' }))], - import: source => state => { - const module = state.modules[source] - return [ - state, - module === undefined - ? error(ioError({ code: 'ENOENT', message: `cannot link ${source}` })) - : ok(module), - ] - }, - // A clock that ticks once per read, so a run's duration is the number of - // reads between its ends and never a real elapsed time. - now: () => state => [ - { ...state, time: state.time + 1 }, - state.clock ? ok(state.time) : error(notImplemented('now')), - ], - sandbox: f => state => [state, ok(/** @type {SandboxResult} */ (f()))], - report: result => state => [{ ...state, results: [...state.results, result] }, ok(undefined)], - reported: () => state => [state, ok(state.results)], -} - -/** @type {RunInstance} */ -const browser = mockRun(map) - -/** @type {(sources: readonly string[], modules: StringMap, clock?: boolean) => BrowserTestReport} */ -const run = (sources, modules, clock = true) => { - /** @type {_State} */ - const state = { time: 100, clock, results: [], modules } - const [, report] = browser(state)(main({ browser: 'proof', sources })) - return unwrap(report) -} - -/** A leaf that passes, taking 2 ms. - * - * @type {() => unknown} - */ -const pass = () => ({ result: ok(undefined), duration: 2 }) - -/** A leaf that fails with an `Error`. - * - * @type {() => unknown} - */ -const fail = () => ({ result: error(new Error('oops')), duration: 3 }) - -export const proof = { - passing: () => { - const report = run(['a'], { a: { proof: { x: pass } } }) - assertEq(report.status, 'passed') - assertEq(report.browser, 'proof') - assertEq(report.totals.tests, 1) - assertEq(report.totals.passed, 1) - assertEq(report.totals.failed, 0) - // Two clock reads bracket the run, and the stand-in ticks once per read. - assertEq(report.duration, 1) - assertEq(report.results[0]?.module, 'a') - assertEq(report.results[0]?.path, '.x') - assertEq(report.results[0]?.duration, 2) - }, - failing: () => { - const report = run(['a'], { a: { proof: { x: pass, y: fail } } }) - assertEq(report.status, 'failed') - assertEq(report.totals.tests, 2) - assertEq(report.totals.failed, 1) - const failed = report.results.filter(r => r.status === 'failed') - assertEq(failed[0]?.path, '.y') - assertEq(failed[0]?.message, 'oops') - }, - // The proof tree a leaf returns is walked by the same shared core `fjs t` - // uses, so a sub-test is a result of its own with a call boundary in its - // path. - subTree: () => { - const report = run(['a'], { - a: { proof: { outer: () => ({ result: ok({ inner: pass }), duration: 0 }) } }, - }) - assertEq(report.totals.tests, 2) - assertEq(report.results[1]?.path, '.outer().inner') - }, - expectedThrow: () => { - const report = run(['a'], { a: { proof: { throw: { boom: fail, quiet: pass } } } }) - assertEq(report.totals.tests, 2) - assertEq(report.totals.failed, 1) - const failed = report.results.filter(r => r.status === 'failed') - assertEq(failed[0]?.path, '.throw.quiet') - assertEq(failed[0]?.message, 'Expected the proof to throw') - }, - // A module without a `proof` export contributes no tests, and an empty run - // still answers a report rather than nothing. - withoutProof: () => { - const report = run(['a'], { a: {} }) - assertEq(report.status, 'passed') - assertEq(report.totals.tests, 0) - }, - // One module that would not link stops the run: the suite never ran, so its - // status is not the one a failing suite gets, and every rejected source is - // still counted as a failed result. - unlinkable: () => { - const report = run(['a', 'missing'], { a: { proof: { x: pass } } }) - assertEq(report.status, 'infrastructure-error') - assertEq(report.totals.tests, 1) - assertEq(report.totals.failed, 1) - assertEq(report.results[0]?.module, 'missing') - assertEq(report.results[0]?.path, '') - assertEq(report.results[0]?.message, 'cannot link missing') - }, - // A runner missing an operation the application needs is reported the same - // way, which is what makes the program's empty error channel true: a page - // waiting on the run always receives a report. - incompleteRunner: () => { - const report = run(['a'], { a: { proof: { x: pass } } }, false) - assertEq(report.status, 'infrastructure-error') - assertEq(report.duration, 0) - assertEq(report.results[0]?.message, 'operation not implemented: now') - assert(report.results.length === 1, report.results) - }, -} diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 8e9041fdb..c4af06c77 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -1,31 +1,20 @@ /** - * Proofs for the browser host adapter and the browser interpretation of the - * host-independent operations. + * Proofs for the browser runner. * - * The adapter reaches the page only through the root element it is handed, so + * The runner reaches the page only through the root element it is handed, so * the DOM stand-in below is enough to drive every rendering branch from Node — * no headless browser, and no global `window`/`document` for these proofs to - * install and unset. What each proof *means* is settled a layer down, by the - * shared core and its own proofs; what is checked here is that a browser run - * reaches it, renders it, and publishes it. - * - * @import { Result } from '../../types/result/types.ts' - * @import { Module } from '../../effects/common/types.ts' - * @import { CommonRun } from '../../effects/browser/module.mjs' - * @import { BrowserTestReport } from './types.ts' + * install and unset. */ -import { assert, assertEq, assertNotNullish } from '../../asserts/module.f.mjs' -import { browserOperationMap } from '../../effects/browser/module.mjs' -import { asyncRun } from '../../effects/module.mjs' -import { pureOk } from '../../effects/module.f.mjs' -import { all as allEffect, sandbox as sandboxEffect } from '../../effects/common/module.f.mjs' -import { renderBrowserReport, startBrowserTestSources } from './module.mjs' -import { unwrap } from '../../types/result/module.f.mjs' +import { runInNewContext } from 'node:vm' + +import { assert, assertEq, assertNotNullish, assertStructurallySame } from '../../asserts/module.f.mjs' +import { renderBrowserReport, runBrowserProofs, startBrowserTests, startBrowserTestSources } from '../browser.mjs' /** @typedef {{ readonly tag: string, attributes: ReadonlyMap, readonly ownerDocument: _Document, textContent: string, children: readonly _Element[], readonly setAttribute: (name: string, value: string) => void, readonly removeAttribute: (name: string) => void, readonly querySelector: (selector: string) => _Element | null, readonly replaceChildren: (...nodes: readonly _Element[]) => void, readonly append: (node: _Element) => void }} _Element */ /** @typedef {{ defaultView: _View | null, readonly createElement: (tag: string) => _Element }} _Document */ -/** @typedef {{ events: readonly CustomEvent[], readonly navigator: { readonly userAgent: string }, readonly dispatchEvent: (event: Event) => boolean, fjsBrowserTestReport?: Promise }} _View */ +/** @typedef {{ events: readonly CustomEvent[], readonly dispatchEvent: (event: Event) => boolean, fjsBrowserTestReport?: Promise }} _View */ /** @type {(node: _Element, name: string) => _Element | null} */ const find = (node, name) => @@ -51,7 +40,7 @@ const element = (document, tag, attributes, states) => { removeAttribute: name => { self.attributes = new Map([...self.attributes].filter(([key]) => key !== name)) }, - // The adapter only ever queries an attribute selector of `[name]` form. + // The runner only ever queries an attribute selector of `[name]` form. querySelector: selector => self.children.reduce( (/** @type {_Element | null} */ acc, child) => acc ?? find(child, selector.slice(1, -1)), @@ -63,7 +52,7 @@ const element = (document, tag, attributes, states) => { } /** - * Builds what the generated page gives the adapter: a root carrying the summary + * Builds what the generated page gives the runner: a root carrying the summary * paragraph and the result list. `states` records every `data-state` written, * so a proof can check the whole progression and not just its last step. * @@ -80,7 +69,6 @@ const page = (withView = true) => { /** @type {_View} */ const view = { events: [], - navigator: { userAgent: 'stand-in browser' }, dispatchEvent: event => { view.events = [...view.events, /** @type {CustomEvent} */ (event)] return true @@ -102,362 +90,339 @@ const page = (withView = true) => { } } -/** Runs one in-memory proof module through the whole browser stack. - * - * @type {(proof: unknown) => Promise} - */ -const run = proof => - startBrowserTestSources(page().root, ['proof'], async () => ({ proof })) +/** @type {(proof: unknown) => ReturnType} */ +const run = proof => runBrowserProofs([['proof', proof]]) /** @type {(element: _Element) => readonly (string | undefined)[]} */ const statuses = element => element.children.map(child => child.attributes.get('data-status')) -/** - * The browser handlers on their own, so the operations the proof application - * never reaches — `fetch`, `await`, a nested `all` — are still exercised. - */ -const operations = browserOperationMap( - effect => commonRun(effect), - async source => ({ source })) - -/** @type {CommonRun} */ -const commonRun = asyncRun(operations) - -const { all, await: awaitOp, fetch: fetchOp, import: importOp, now, sandbox } = operations - export const proof = { - // The whole stack: a module is linked, its proofs run, each result is - // rendered as it lands, and the report is published and announced. - passing: async () => { - const { root, summary, results, view, states } = page() - const report = await startBrowserTestSources(root, ['a'], async () => ({ - proof: { x: () => undefined }, - })) + namedThrow: async () => { + const named = { throw: () => { throw 'expected' } }.throw + const report = await run({ extracted: named }) assertEq(report.status, 'passed') - assertEq(report.browser, 'stand-in browser') - assertEq(report.totals.tests, 1) - assertEq(report.results[0]?.path, '.x') - assertEq(statuses(results).join(','), 'passed') - // The page names a test exactly as `fjs t` names it. The two spellings - // had drifted — `./a .x` here against the call expression there — which - // is the thing a shared runner is supposed to make impossible. - assert( - results.children[0]?.textContent.startsWith('PASS import("a").proof.x()'), - results.children[0]?.textContent) - assert(summary.textContent.startsWith('1 passed, 0 failed'), summary.textContent) - assertEq(states.join(','), 'loading,running,passed') - assertEq(view.events.length, 1) - assertEq(/** @type {BrowserTestReport} */ (view.events[0]?.detail).status, 'passed') - assertEq(await view.fjsBrowserTestReport, report) }, - failing: async () => { - const { root, results, runButton } = page() - const report = await startBrowserTestSources(root, ['a'], async () => ({ - proof: { boom: () => { throw new Error('bang') } }, - })) + path: async () => { + const report = await run({ 'a.b': () => undefined }) + assertEq(report.results[0]?.path, '["a.b"]') + }, + arbitraryThrow: async () => { + const report = await run({ fail: () => { throw Object.create(null) } }) assertEq(report.status, 'failed') - assertEq(report.results[0]?.message, 'bang') - assert((report.results[0]?.stack ?? '').includes('bang')) - assertEq(statuses(results).join(','), 'failed') - // The control is available again the moment the run reaches a terminal - // state, and was not while it was loading or running. - assert(!runButton.attributes.has('disabled')) + assertEq(report.results[0]?.message, 'Unknown thrown value') }, - expectedThrow: async () => { - const report = await run({ throw: { boom: () => { throw 'expected' } } }) - assertEq(report.status, 'passed') + errorFields: async () => { + const error = new Proxy(new Error(), { + get: (target, property) => property === 'message' || property === 'stack' + ? Symbol(property) + : Reflect.get(target, property), + }) + const report = await run({ fail: () => { throw error } }) + assertEq(report.results[0]?.message, 'Symbol(message)') + assertEq(report.results[0]?.stack, 'Symbol(stack)') }, - // Only a real promise is an asynchronous value, which is exactly the rule - // `fjs t` follows: the browser `sandbox` awaits one and reports what it - // resolves to. - promise: async () => { - const report = await run({ nested: () => Promise.resolve({ inner: () => undefined }) }) - assertEq(report.totals.tests, 2) - assertEq(report.totals.failed, 0) - assertEq(report.results[1]?.path, '.nested().inner') + errorAccessorThrows: async () => { + const error = new Error('hidden') + Object.defineProperty(error, 'message', { + get: () => { throw new Error('message getter failed') }, + }) + const report = await run({ fail: () => { throw error } }) + assertEq(report.status, 'failed') + assertEq(report.results[0]?.message, 'Unknown thrown value') + assertEq(report.results[0]?.stack, 'Unknown thrown value') }, - rejectedPromise: async () => { - const report = await run({ nested: () => Promise.reject(new Error('later')) }) + revokedErrorProxy: async () => { + const { proxy, revoke } = Proxy.revocable(new Error('revoked'), {}) + revoke() + const report = await run({ fail: () => { throw proxy } }) assertEq(report.status, 'failed') - assertEq(report.results[0]?.message, 'later') + assertEq(report.results[0]?.message, 'Unknown thrown value') }, - // ...and an ordinary object carrying a `then` proof is a proof tree, never - // a thenable to assimilate. - thenIsATestName: async () => { - const report = await run({ nested: () => ({ then: () => undefined }) }) + crossRealmError: async () => { + // An Error from another realm is not `instanceof Error` here, and its + // stack is what the report exists to carry. + const other = runInNewContext( + '({ fail: () => { throw new Error(\'cross boom\') } })') + const report = await run({ fail: other.fail }) + assertEq(report.results[0]?.message, 'cross boom') + const stack = report.results[0]?.stack ?? '' + assert(stack !== 'cross boom', stack) + assert(stack.includes('cross boom'), stack) + }, + errorWithoutStack: async () => { + const error = new Error('no stack') + const report = await run({ fail: () => { throw Object.assign(error, { stack: undefined }) } }) + assertEq(report.results[0]?.message, 'no stack') + assertEq(report.results[0]?.stack, 'no stack') + }, + expectedThrow: async () => { + const report = await run({ throw: { silent: () => undefined } }) + assertEq(report.status, 'failed') + assertEq(report.results[0]?.message, 'Expected the proof to throw') + }, + crossRealmPromise: async () => { + // A promise built in another realm is not `instanceof Promise`. The + // runner has to await it anyway and walk the tree it resolves to, + // otherwise a rejected cross-realm promise is reported as a pass. + const other = runInNewContext('({ resolve: value => Promise.resolve(value) })') + const report = await run({ + nested: () => other.resolve({ child: () => { throw 'boom' } }), + }) + assertEq(report.totals.tests, 2) + assertEq(report.totals.failed, 1) + assertEq(report.results[1]?.path, '.nested().child') + }, + spoofedPromiseTag: async () => { + const report = await run({ + nested: () => ({ + [Symbol.toStringTag]: 'Promise', + then: /** @type {(...args: (() => void)[]) => void} */ ((...args) => { args[0]?.() }), + }), + }) assertEq(report.totals.tests, 2) assertEq(report.results[1]?.path, '.nested().then') }, - // A module that will not link stops the run before any proof body, and says - // so with a status an automated consumer must not read as a failing suite. - unlinkable: async () => { - const { root, summary, states } = page() - const report = await startBrowserTestSources(root, ['a'], async () => { - throw new Error('404') + frozenPromiseTag: async () => { + // A non-extensible spoof leaves the runner nothing to shadow, the same + // dead end a pinned promise reaches. It is still an ordinary proof + // tree, so it is walked rather than reported as a brand-check failure. + const report = await run({ + nested: () => Object.freeze({ + [Symbol.toStringTag]: 'Promise', + then: () => undefined, + }), }) - assertEq(report.status, 'infrastructure-error') + assertEq(report.totals.tests, 2) + assertEq(report.totals.failed, 0) + assertEq(report.results[1]?.path, '.nested().then') + }, + exportedTreeThrows: async () => { + // The exported tree is read before any test runs, and reading it runs + // user code as well. The module fails; the page still gets its report. + const p = page() + const report = await startBrowserTests(p.root, + [['m', { get bad() { throw new Error('enumerating') } }]]) + assertEq(report.status, 'failed') + assertStructurallySame({ ...report.totals }, { tests: 1, passed: 0, failed: 1 }) + assertEq(report.results[0]?.module, 'm') + assertEq(report.results[0]?.message, 'enumerating') + assertStructurallySame([...p.states], ['running', 'failed']) + assertEq(p.view.events.length, 1) + }, + returnedTreeThrows: async () => { + // Reading the returned tree runs user code. When it throws, the test + // that produced the value fails and the page still reaches a terminal + // state — a rejected run would leave it in `running` forever. + const p = page() + const report = await startBrowserTests(p.root, + [['m', { nested: () => ({ get bad() { throw new Error('getter') } }) }]]) + assertEq(report.status, 'failed') + assertStructurallySame({ ...report.totals }, { tests: 1, passed: 0, failed: 1 }) + assertEq(report.results[0]?.message, 'getter') + assertStructurallySame([...p.states], ['running', 'failed']) + assertEq(p.view.events.length, 1) + }, + speciesResultIsNotAPromise: async () => { + // `then` builds its result through `constructor[Symbol.species]`, and a + // promise can make that an ordinary object. The run has to answer with + // the promise it subscribed to, not with what `then` handed back, or + // the test ends before the promise settles and the species object + // itself lands in the report. + const species = function (/** @type {(...args: (() => void)[]) => void} */ executor) { + executor(() => undefined, () => undefined) + return { notAPromise: true } + } + const promised = new Promise(resolve => + setTimeout(resolve, 1, { child: () => { throw 'boom' } })) + Object.defineProperty(promised, 'constructor', + { value: { [Symbol.species]: species }, configurable: true }) + const report = await run({ nested: () => promised }) + assertEq(report.totals.tests, 2) assertEq(report.totals.failed, 1) - assertEq(report.results[0]?.module, 'a') - assertEq(report.results[0]?.message, '404') - assert(summary.textContent.startsWith('Infrastructure error: 1 failed'), summary.textContent) - assertEq(states.join(','), 'loading,running,infrastructure-error') + assertEq(report.results[1]?.path, '.nested().child') }, - // The importer is page code, so obtaining the promise is itself a failure - // point: a synchronous throw is a load failure, not an escape past a - // `loading` state no report ever replaces. - importerThrowsSynchronously: async () => { - const { root } = page() - const report = await startBrowserTestSources(root, ['a'], () => { - throw new Error('bad specifier') - }) - assertEq(report.status, 'infrastructure-error') - assertEq(report.results[0]?.message, 'bad specifier') + reportingThrows: async () => { + // Announcing a result as it lands is the page's own rendering. It must + // not take the run down with it: the report is what the page waits for. + const report = await runBrowserProofs([['m', { t: () => undefined }]], + () => { throw new Error('render') }) + assertEq(report.status, 'passed') + assertEq(report.totals.passed, 1) }, - // A long suite runs to completion with a yield between every launch. - manyLeaves: async () => { - const proof = Object.fromEntries( - [...new Array(60).keys()].map(i => [`t${i}`, () => undefined])) - const report = await run(proof) - assertEq(report.totals.tests, 60) - assertEq(report.totals.passed, 60) + thenIsATestName: async () => { + // A `then` proof entry is a test called `then`, never a thenable for + // the runner to adopt. + const report = await run({ then: () => undefined }) + assertEq(report.totals.tests, 1) + assertEq(report.results[0]?.path, '.then') }, - // Reading the tree a proof returns runs user code, and the shared traversal - // has no `try`/`catch` to give it — so a throwing getter panics *through* - // the run. The page must still reach a terminal state and still publish a - // report: a rejected run left in `running` is the one outcome an automated - // controller cannot act on. - hostileProofTree: async () => { - const { root, view, states } = page() - const report = await startBrowserTestSources(root, ['a'], async () => ({ - proof: { hostile: () => ({ get boom() { throw new Error('trap') } }) }, - })) - assertEq(report.status, 'infrastructure-error') - assertEq(report.results[0]?.message, 'trap') - assertEq(states.join(','), 'loading,running,infrastructure-error') - assertEq(view.events.length, 1) + batches: async () => { + // More leaves than one batch holds, so the batch loop recurses. + const report = await run(Object.fromEntries( + Array.from({ length: 30 }, (_, index) => [`t${index}`, () => undefined]))) + assertEq(report.totals.tests, 30) + assertEq(report.totals.passed, 30) }, - // The summary must not keep showing idle text through loading: it is - // replaced the instant a run starts, before any import has had a chance to - // settle — even one that never does. - loadingSummaryIsSynchronous: () => { - const { root, summary } = page() - void startBrowserTestSources(root, ['a.mjs', 'b.mjs'], () => new Promise(() => undefined)) - assertEq(summary.textContent, 'Loading 0/2') + render: async () => { + const p = page() + const report = await startBrowserTests(p.root, + [['m', { ok: () => undefined, bad: () => { throw 'x' } }]]) + assertEq(report.status, 'failed') + assertStructurallySame([...p.states], ['running', 'failed']) + assertEq(p.summary.textContent, `1 passed, 1 failed (${report.duration.toFixed(1)} ms)`) + assertStructurallySame([...statuses(p.results)], ['passed', 'failed']) + const event = assertNotNullish(p.view.events[0]) + assertEq(event.type, 'fjs-browser-test-complete') + assertEq(event.detail, report) + assertEq(await p.view.fjsBrowserTestReport, report) + }, + renderWithoutView: async () => { + // A detached document has no window: the run still renders, and + // nothing is published or announced. + const p = page(false) + const report = await startBrowserTests(p.root, [['m', { ok: () => undefined }]]) + assertEq(report.status, 'passed') + assertEq(p.summary.textContent, `1 passed, 0 failed (${report.duration.toFixed(1)} ms)`) + assertEq(p.view.events.length, 0) + assertEq(p.view.fjsBrowserTestReport, undefined) }, - // ...and it counts up as modules link, so a slow graph shows progress - // rather than one frozen line. - loadingProgress: async () => { - const { root, summary } = page() - /** @type {(module: Module) => void} */ + renderReport: () => { + // The renderer is exported on its own for a controller that already + // holds a report. + const p = page() + renderBrowserReport(p.root, { + status: 'passed', + browser: 'test', + totals: { tests: 1, passed: 1, failed: 0 }, + duration: 1, + results: [{ module: 'm', path: '.t', status: 'passed', duration: 0.5 }], + }) + assertEq(p.summary.textContent, '1 passed, 0 failed (1.0 ms)') + assertEq(p.results.children[0]?.textContent, 'PASS m .t (0.5 ms)') + }, + sources: async () => { + const p = page() + const report = await startBrowserTestSources(p.root, ['a.mjs', 'b.mjs'], + source => Promise.resolve({ proof: { [source]: () => undefined } })) + assertEq(report.status, 'passed') + assertEq(report.totals.tests, 2) + assertStructurallySame([...p.states], ['loading', 'running', 'passed']) + assertEq(await p.view.fjsBrowserTestReport, report) + }, + sourcesLoadingSummaryIsSynchronous: () => { + // The summary must not keep showing idle text through loading: it is + // replaced the instant a run starts, before any import has had a + // chance to settle — even one that never does. + const p = page() + void startBrowserTestSources(p.root, ['a.mjs', 'b.mjs'], () => new Promise(() => undefined)) + assertEq(p.summary.textContent, 'Loading 0/2') + }, + sourcesProgress: async () => { + const p = page() + /** @type {(module: { readonly proof?: unknown }) => void} */ let release = () => undefined - /** @type {Promise} */ + /** @type {Promise<{ readonly proof?: unknown }>} */ const pending = new Promise(resolve => { release = resolve }) - const done = startBrowserTestSources(root, ['a.mjs', 'b.mjs'], + const done = startBrowserTestSources(p.root, ['a.mjs', 'b.mjs'], source => source === 'a.mjs' ? Promise.resolve({ proof: {} }) : pending) await Promise.resolve() await Promise.resolve() - assertEq(summary.textContent, 'Loading 1/2: a.mjs') + assertEq(p.summary.textContent, 'Loading 1/2: a.mjs') release({ proof: {} }) assertEq((await done).status, 'passed') }, - // The same action starts every run: nothing but the `Run` control's own - // state stands between a completed run and the next one. - newRunAfterCompletion: async () => { - const { root, runButton, states } = page() - /** @type {() => Promise} */ - const load = () => Promise.resolve({ proof: { t: () => undefined } }) - await startBrowserTestSources(root, ['a.mjs'], load) - assert(!runButton.attributes.has('disabled')) - const second = await startBrowserTestSources(root, ['a.mjs'], load) - assertEq(second.status, 'passed') - assertEq(second.totals.tests, 1) - assertEq(states.join(','), 'loading,running,passed,loading,running,passed') - }, - // Rendering a result is the page's own code, so it is a failure point of - // the page and not of the run: a renderer that throws must not cost the - // report every consumer is waiting for. - renderingThrows: async () => { - const { root, results } = page() - const append = results.append - const report = await startBrowserTestSources(root, ['a.mjs'], async () => { - // Break rendering only once the run is under way, so the page is - // built normally and only the per-result append fails. - Object.assign(results, { append: () => { throw new Error('render') } }) - return { proof: { t: () => undefined } } - }) - Object.assign(results, { append }) - assertEq(report.status, 'passed') - assertEq(report.totals.passed, 1) - }, - // Describing a panic reads the value that caused it, so a value every trap - // of which throws *itself* makes the description panic in turn. That is the - // last handler there is: it may not fail, or the guard against a stuck page - // becomes the thing that sticks it. - unreadableFailure: async () => { - /** @type {ProxyHandler} */ - const handler = {} - const hostile = new Proxy({}, handler) - const rethrow = () => { throw hostile } - Object.assign(handler, { has: rethrow, get: rethrow, ownKeys: rethrow }) - const { root, states } = page() - const report = await startBrowserTestSources(root, ['a'], async () => ({ - proof: { boom: () => { throw hostile } }, - })) + sourcesImporterThrows: async () => { + // An importer that throws before it returns a promise is a loader + // failure like any other: the page must not be left in `loading` with + // no report and no completion event. + const p = page() + const report = await startBrowserTestSources(p.root, ['bad.mjs'], + source => { throw new Error(`no loader for ${source}`) }) assertEq(report.status, 'infrastructure-error') - assertEq(report.results[0]?.message, 'The run failed with a value that cannot be read') - assertEq(states.join(','), 'loading,running,infrastructure-error') + assertStructurallySame({ ...report.totals }, { tests: 1, passed: 0, failed: 1 }) + assertEq(report.results[0]?.message, 'no loader for bad.mjs') + assertStructurallySame([...p.states], ['loading', 'infrastructure-error']) + assertEq(p.view.events.length, 1) }, - // `infrastructure-error` covers a panic and a runner missing an operation as - // well as a module that would not link, so the summary must not diagnose - // every one of them as a loading failure. - infrastructureSummaryNamesNoCause: () => { - const { root, summary } = page() - renderBrowserReport(root, { - status: 'infrastructure-error', - browser: 'x', - totals: { tests: 1, passed: 0, failed: 1 }, - duration: 0, - results: [{ module: '', path: '', status: 'failed', duration: 0, message: 'no sandbox', stack: '' }], - }) - assert(!summary.textContent.includes('to load'), summary.textContent) - assert(summary.textContent.startsWith('Infrastructure error: 1 failed'), summary.textContent) + runControlAbsentButtonIsIgnored: async () => { + // An embedding root with no `[data-test-run]` control is still + // supported: `setState` finds nothing to toggle and moves on rather + // than throwing. + /** @type {string[]} */ + const states = [] + /** @type {_Document} */ + const document = { + defaultView: null, + createElement: tag => element(document, tag, [], states), + } + const root = element(document, 'main', ['data-browser-tests'], states) + root.replaceChildren( + element(document, 'p', ['data-test-summary'], states), + element(document, 'ol', ['data-test-results'], states)) + const report = await startBrowserTests(/** @type {Element} */ (/** @type {unknown} */ (root)), + [['m', { ok: () => undefined }]]) + assertEq(report.status, 'passed') }, - // A root whose document has no window still runs and still answers: there - // is simply nowhere to publish the promise or dispatch the event. - withoutView: async () => { - const { root, view } = page(false) - const report = await startBrowserTestSources(root, ['a'], async () => ({ - proof: { x: () => undefined }, - })) + runControlDisabledWhileActive: async () => { + // `Run` must be passive — genuinely disabled, not just click-ignoring — + // for the whole span between a click and the next terminal state: + // through loading and through execution. + const p = page() + /** @type {(module: { readonly proof?: unknown }) => void} */ + let release = () => undefined + /** @type {Promise<{ readonly proof?: unknown }>} */ + const pending = new Promise(resolve => { release = resolve }) + const done = startBrowserTestSources(p.root, ['a.mjs'], () => pending) + await Promise.resolve() + assertEq(p.states[0], 'loading') + assertEq(p.runButton.attributes.has('disabled'), true) + release({ proof: { t: () => undefined } }) + await Promise.resolve() + await Promise.resolve() + assertEq(p.runButton.attributes.has('disabled'), true) + const report = await done assertEq(report.status, 'passed') - assertEq(report.browser, '') - assertEq(view.events.length, 0) - assertEq(view.fjsBrowserTestReport, undefined) + // Terminal state hands control back: a new run can be started. + assertEq(p.runButton.attributes.has('disabled'), false) }, - // A root with none of the page's elements is rendered into without a throw: - // an embedder may host the runner in a bare container. - renderWithoutElements: () => { - const { root, states } = page() - root.replaceChildren() - renderBrowserReport(root, { - status: 'passed', - browser: 'x', - totals: { tests: 0, passed: 0, failed: 0 }, - duration: 0, - results: [], - }) - assertEq(states.join(','), 'passed') + runControlReenabledAfterFailure: async () => { + // A failed or infrastructure-error run is just as terminal as a passed + // one: `Run` reactivates either way. + const p = page() + const report = await startBrowserTestSources(p.root, ['bad.mjs'], + source => Promise.reject(new Error(`offline: ${source}`))) + assertEq(report.status, 'infrastructure-error') + assertEq(p.runButton.attributes.has('disabled'), false) }, - operations: { - // `fetch` reads a `data:` URL rather than a network one, so the proof - // stays offline while still going through the realm's own `fetch`. - fetch: async () => { - const r = await fetchOp('data:text/plain,ok') - assert(r[0] === 'ok', r) - }, - fetchFailure: async () => { - const r = await fetchOp('not-a-scheme://x') - assert(r[0] === 'error', r) - assertEq(r[1][0], 'ioError') - }, - import: async () => { - const r = await importOp('./x.mjs') - assertEq(/** @type {Module} */ (unwrap(r)).source, './x.mjs') - }, - awaitsPromise: async () => { - assertEq(unwrap(await awaitOp(Promise.resolve(7)))[0], 7) - }, - awaitsPlainValue: async () => { - assertEq(unwrap(await awaitOp(7))[0], 7) - }, - // Epoch milliseconds, as the Node runner answers — but read through - // `performance`, so two reads never come out in the wrong order however - // the system clock is adjusted between them. - now: async () => { - const before = unwrap(await now()) - const after = unwrap(await now()) - assert(before > Date.UTC(2020, 0, 1), before) - assert(after >= before, [before, after]) - }, - sandboxMeasures: async () => { - const { result, duration } = unwrap(await sandbox(() => 1)) - assertEq(unwrap(result), 1) - assert(duration >= 0, duration) - }, - all: async () => { - const results = unwrap(await all(pureOk(1), pureOk(2))) - assertEq(results.map(unwrap).join(','), '1,2') - }, - // Slicing must not serialize: every child is *started* before any is - // awaited, so a child waiting on something a later sibling produces - // still sees that sibling run. Awaiting each slice before starting the - // next hangs this — the releaser sits in the second slice, which is - // never reached — on a graph the Node runner completes. - allStartsEveryChildBeforeAwaiting: async () => { - // The gate is opened either by the eleventh child — which is the - // property under test — or, after far more turns of the event loop - // than every launch can need, by the fallback below. Which one - // opened it is the assertion. - // - // Counting turns rather than milliseconds is deliberate: this proof - // runs concurrently with the rest of the suite, so a wall-clock - // deadline measures how loaded the machine is, not what `all` did. - // The fallback exists so a serializing `all` *fails* here instead of - // hanging the run. - /** @type {string | null} */ - let openedBy = null - /** @type {(value: unknown) => void} */ - let release = () => undefined - /** @type {Promise} */ - const gate = new Promise(resolve => { release = resolve }) - // Whoever opens the gate *first* is recorded. A later opener must - // not overwrite it: a serializing `all` still reaches the sibling - // eventually, just far too late to have been what unblocked the - // first child. - /** @type {(who: string) => void} */ - const open = who => { - if (openedBy === null) { openedBy = who } - release(0) - } - const fallback = async () => { - for (let turn = 0; turn < 50 && openedBy === null; turn += 1) { - await new Promise(resolve => { setTimeout(resolve, 0) }) - } - open('the fallback') - } - void fallback() - const filler = sandboxEffect(() => 0) - const waits = sandboxEffect(() => gate) - const releases = sandboxEffect(() => { open('a later sibling'); return 0 }) - const many = [waits, ...[...new Array(9).keys()].map(() => filler), releases] - const results = unwrap(await commonRun(allEffect(...many))) - assertEq(openedBy, 'a later sibling') - assertEq(results.length, 11) - }, - // `all` hands the event loop back between launches, which is the only - // thing that lets a page paint mid-suite: a task queued before the call - // has to run before it resolves. Without the yield every child settles - // on microtasks and no task gets a turn — which is what this asserts, - // since the effects below perform nothing. - // - // The task queued here is a `MessageChannel` message rather than a - // `setTimeout`, because the two are not interchangeable across engines. - // Bun delivers port messages until none are left before it runs a due - // timer, so 59 yields there leave a `setTimeout(0)` queued behind them - // and this proof would report a yielding `all` as a non-yielding one. - // Asserting on the queue `all` actually posts to states the property - // — that a launch ends the task, so anything already queued runs — in - // terms every engine agrees on. - allYieldsBetweenLaunches: async () => { - let delivered = false - const { port1, port2 } = new MessageChannel() - port1.onmessage = () => { port1.close(); delivered = true } - port2.postMessage(0) - const many = [...new Array(60).keys()].map(i => pureOk(i)) - const results = unwrap(await all(...many)) - assertEq(results.length, 60) - assertEq(results.map(unwrap).join(','), many.map((_, i) => i).join(',')) - assert(delivered, 'all resolved without yielding to the event loop') - }, + runControlNewRunAfterCompletion: async () => { + // The same action starts every run: nothing but the `Run` control's + // own state stands between a completed run and the next one. + const p = page() + await startBrowserTestSources(p.root, ['a.mjs'], + () => Promise.resolve({ proof: { t: () => undefined } })) + assertEq(p.runButton.attributes.has('disabled'), false) + const second = await startBrowserTestSources(p.root, ['a.mjs'], + () => Promise.resolve({ proof: { t: () => undefined } })) + assertEq(second.status, 'passed') + assertStructurallySame([...p.states], + ['loading', 'running', 'passed', 'loading', 'running', 'passed']) + }, + sourcesLoadFailure: async () => { + const p = page() + const report = await startBrowserTestSources(p.root, ['ok.mjs', 'bad.mjs'], + source => source === 'bad.mjs' + ? Promise.reject(new Error('offline')) + : Promise.resolve({ proof: { t: () => undefined } })) + assertEq(report.status, 'infrastructure-error') + // The totals have to agree with `results`: a consumer reading + // `0 of 0` would take a broken suite for an empty one. + assertStructurallySame({ ...report.totals }, { tests: 1, passed: 0, failed: 1 }) + assertEq(report.results[0]?.module, 'bad.mjs') + assertEq(report.results[0]?.message, 'offline') + assertStructurallySame([...p.states], ['loading', 'infrastructure-error']) + assert(p.summary.textContent.startsWith('Infrastructure error: 1 failed to load'), + p.summary.textContent) + assertStructurallySame([...statuses(p.results)], ['failed']) + assertEq(p.view.events.length, 1) }, } diff --git a/fjs/emergent_testing/browser/species.proof.mjs b/fjs/emergent_testing/browser/species.proof.mjs new file mode 100644 index 000000000..11303e009 --- /dev/null +++ b/fjs/emergent_testing/browser/species.proof.mjs @@ -0,0 +1,45 @@ +import { assertEq } from '../../asserts/module.f.mjs' +import { runBrowserProofs } from '../browser.mjs' + +/** + * A genuine promise whose `then` always throws: the result promise is built + * through `constructor[Symbol.species]`, and this `constructor` has none to + * give. `configurable` decides whether the runner can shadow the property for + * the length of one subscription. + * + * @type {(configurable: boolean) => Promise} + */ +const throwingSpeciesPromise = configurable => { + const promised = Promise.resolve({ + child: () => { throw 'boom' }, + }) + const constructor = {} + Object.defineProperty(constructor, Symbol.species, { + get: () => { throw new Error('species') }, + }) + Object.defineProperty(promised, 'constructor', { value: constructor, configurable }) + return promised +} + +/** @type {(promised: Promise) => ReturnType} */ +const run = promised => runBrowserProofs([['proof', { nested: () => promised }]]) + +export const proof = { + throwingSpecies: async () => { + // The intrinsic Promise shadows the hostile `constructor` while the + // handlers are attached, so the resolved sub-tree still runs. + const report = await run(throwingSpeciesPromise(true)) + assertEq(report.totals.tests, 2) + assertEq(report.totals.failed, 1) + assertEq(report.results[1]?.path, '.nested().child') + }, + pinnedThrowingSpecies: async () => { + // Nothing to shadow, so the promise can never be subscribed to. The + // test that produced it fails, rather than passing on a result the + // runner never observed. + const report = await run(throwingSpeciesPromise(false)) + assertEq(report.totals.tests, 1) + assertEq(report.totals.failed, 1) + assertEq(report.results[0]?.message, 'species') + }, +} diff --git a/fjs/emergent_testing/browser/types.ts b/fjs/emergent_testing/browser/types.ts deleted file mode 100644 index f947e00c7..000000000 --- a/fjs/emergent_testing/browser/types.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Types for the browser proof application. - * - * @module - */ - -import type { CommonOp, Module } from '../../effects/common/types.ts' -import type { Effect } from '../../effects/types.ts' -import type { IoResult } from '../../effects/common/types.ts' -import type { ReportOp, TestResult } from '../types.ts' - -/** - * The operations the browser application performs: the host-independent set - * every runner implements, plus the two that record normalized results. - * - * There is nothing browser-specific in it, and that is the design rather than - * an accident — the DOM is the *adapter's* business - * ([`./module.mjs`](./module.mjs)), never the application's. A page, a proof - * with a stand-in interpreter, and a future headless controller therefore run - * the very same program. - */ -export type BrowserOp = CommonOp | ReportOp - -/** - * How a whole run ended. `infrastructure-error` is not a third kind of test - * failure: it says the suite never got to run — a module that would not link, a - * runner missing an operation — which an automated consumer must not read as - * "the proofs failed". - */ -export type ReportStatus = 'passed' | 'failed' | 'infrastructure-error' - -/** - * The serializable answer of a run, independent of the runner that produced it - * and of the page that rendered it. - */ -export type BrowserTestReport = { - readonly status: ReportStatus - readonly browser: string - readonly totals: { - readonly tests: number - readonly passed: number - readonly failed: number - } - readonly duration: number - readonly results: readonly TestResult[] -} - -/** - * What the host supplies to a run: the proof modules to link, and the name to - * record the realm under. - * - * `browser` is data rather than a `navigator` read, for the reason every other - * capability here is an operation — the application must be runnable outside a - * browser, and a proof that had to install a global `navigator` to check a - * report would be testing the stub. - */ -export type BrowserOptions = { - readonly browser: string - readonly sources: readonly string[] -} - -/** - * A run: options in, a report out. - * - * **The error channel is `never`**, and it is earned rather than asserted: a - * module that will not link and an operation the runner lacks are both - * *reported*, as an `infrastructure-error` report. A page waiting on the run - * has nowhere to put a failure — leaving it in `running` with no report and no - * completion event is the one outcome an automated controller cannot act on. - */ -export type BrowserProgram = (options: BrowserOptions) => Effect - -/** @internal One source paired with what linking it answered. */ -export type _Loaded = readonly[string, IoResult] diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index 142f8ab12..a32868040 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -2,34 +2,25 @@ * Test-framework helpers for running and reporting FunctionalScript tests. * * Two parallel execution paths: - * - `runModule` / `Reporter` — self-hosted Effects runner; sandboxes each - * leaf call individually and accumulates `TestState`. **Both** `fjs t` and - * the browser runner (`./browser/module.f.mjs`) go through it: proof-tree - * walking, the structural `throw` expectation, promise resolution, path - * formatting and the totals are decided here once, and each host differs only - * in its `Reporter` and in the runner that interprets `sandbox`. + * - `runModule` / `Reporter` — self-hosted Effects runner used by `fjs t`; + * sandboxes each leaf call individually and accumulates `TestState`. * - `registerModule` / `TestContext` — registers tests with an external * framework (Node `--test`, Bun, Deno) at import time; the framework owns * scheduling and pass/fail counting. * - * `recordingReporter` is the host-independent reporter of the first path: it - * normalizes each leaf into a `TestResult` carrying no terminal text and no DOM - * and hands it to the `report` operation, leaving presentation to the host. - * * @module * * @import { Operation } from '../effects/types.ts' - * @import { Effect, Func, NotImplemented } from '../effects/types.ts' + * @import { Effect, NotImplemented } from '../effects/types.ts' * @import { LoadModuleOperations, ModuleMap } from '../dev/types.ts' - * @import { Report, Reported, TestFn, TestEntry, TestResult, TestSet, Path, Reporter, _TestState, _TestAndPath } from './types.ts' + * @import { TestFn, TestEntry, TestSet, Path, Reporter, _TestState, _TestAndPath } from './types.ts' * @import { All, Await, Env, IoChannel, NodeProgram, NodeProgramOptions, Program, Sandbox, SandboxResult, Test, TestContext, Write, WriteConsoles } from '../effects/node/types.ts' */ import { reset, fgGreen, fgRed, bold, csiWrite } from '../text/sgr/module.f.mjs' -import { allOk, awaitIfPromise, sandbox } from '../effects/common/module.f.mjs' -import { errorExit, errorMessage, errorSummary, exitStep, test } from '../effects/node/module.f.mjs' +import { allOk, awaitIfPromise, errorExit, errorMessage, errorSummary, exitStep, sandbox, test } from '../effects/node/module.f.mjs' import { - catchStep, do_, history, historyStep, mapStep, pureError, pureOk, resultStep, step, + catchStep, history, historyStep, mapStep, pureError, pureOk, resultStep, step, } from '../effects/module.f.mjs' import { loadModuleMap } from '../dev/module.f.mjs' import { invert } from '../types/result/module.f.mjs' @@ -330,31 +321,14 @@ export const fmtPath = path => path.reduce((/** @type {string} */ acc, k) => acc + fmtKey(k), '') /** - * A fully-qualified test identifier, from a module and an **already-rendered** - * key chain: `import("./math.proof.f.mjs").proof.add()`. - * - * This is the one place the format lives, and it takes the rendered chain - * rather than a {@link Path} so that a reporter holding a {@link TestResult} — - * whose `path` is already a string — names a test exactly as `fjs t` does. It - * did not, and the browser page rendered `./math.proof.f.mjs .add` while the - * terminal rendered the call expression: one identifier in two spellings, which - * is the drift a shared runner is supposed to make impossible. - * - * @type {(file: string, path: string) => string} - */ -export const fmtCall = (file, path) => - `import(${JSON.stringify(file)}).proof${path}()` - -/** - * {@link fmtCall} over a {@link Path} that has not been rendered yet, e.g. - * `import("./math.proof.f.ts").proof.add()` or - * `import("./a.proof.f.ts").proof.users[3].name()`. + * Formats a fully-qualified test identifier as a JS-like expression, e.g. + * `import("./math.proof.f.ts").add()` or `import("./a.proof.f.ts").users[3].name()`. * Self-contained per line — suitable for parallel output and as a CLI filter argument. * * @type {(file: string, path: Path) => string} */ export const fmtImport = (file, path) => - fmtCall(file, fmtPath(path)) + `import(${JSON.stringify(file)}).proof${fmtPath(path)}()` /** * Renders a key chain for terminal output: `| ` per level of depth, followed @@ -395,82 +369,6 @@ export const ghEscape = s => export const defaultTest = (file, path, { fn, throws }) => mapStep(sandbox(fn), r => throws ? { ...r, result: invert(r.result) } : r) -/** What a `throws` leaf that returned cleanly is reported as. */ -const expectedThrow = 'Expected the proof to throw' - -/** - * The message and stack to report a thrown value by. - * - * An `Error` thrown from another realm — an iframe, a worker — is not - * `instanceof Error` here, and its stack is the very thing a report exists to - * carry. What the fields say is therefore the test, not where the value was - * made: anything carrying `message` or `stack` is read as the failure it - * describes, and everything else by its own text. - * - * @type {(error: unknown) => readonly[string, string]} - */ -export const errorDetails = error => { - if (error !== null && (typeof error === 'object' || typeof error === 'function') - && ('message' in error || 'stack' in error)) { - const { message, stack } = /** @type {{ readonly message?: unknown, readonly stack?: unknown }} */ (error) - const described = String(message) - return [described, stack === undefined ? described : String(stack)] - } - const fallback = String(error) - return [fallback, fallback] -} - -/** - * Normalizes one leaf outcome into the {@link TestResult} every reporter - * renders from. - * - * `r` is what {@link Reporter.test} answered, so a `throws` leaf has already - * been inverted by {@link defaultTest}: an `error` there means the proof - * returned when it was expected to throw, which is why that case is named - * rather than described by the value it returned. - * - * @type {(file: string, path: Path, r: SandboxResult, throws: boolean) => TestResult} - */ -export const testResult = (file, path, { result, duration }, throws) => { - const [status, value] = result - const common = { module: file, path: fmtPath(path), duration } - if (status === 'ok') { return { ...common, status: 'passed' } } - const [message, stack] = throws ? [expectedThrow, ''] : errorDetails(value) - return { ...common, status: 'failed', message, stack } -} - -/** Records one normalized leaf result as it lands. - * - * @type {Func} - */ -export const report = do_('report') - -/** Reads back every result {@link report} has recorded. - * - * @type {Func} - */ -export const reported = do_('reported') - -/** - * The reporter that answers in {@link TestResult}s instead of rendering: each - * leaf is normalized and handed to the {@link report} operation, and the run's - * consumer reads the sequence back with {@link reported}. - * - * **Its `summary` writes nothing**, and that is not an omission. Pass, fail and - * total are `results.length` and a count of the failed ones, so a summary event - * would restate what the recorded results already say — and a consumer that - * derives them cannot disagree with itself about how many tests ran. The - * terminal reporter keeps its own `summary` because a line of text is genuinely - * not derivable from the results a user has already scrolled past. - * - * @type {Reporter} - */ -export const recordingReporter = { - result: (file, path, r, throws) => report(testResult(file, path, r, throws)), - summary: () => pureOk(undefined), - test: defaultTest, -} - /** @type {(file: string, path: Path, color: string, label: string, duration: number) => string} */ const fmtResultLine = (file, path, color, label, duration) => `${fmtImport(file, path)}: ${color}${label}${reset}, ${timeFormat(duration)}` diff --git a/fjs/emergent_testing/proof.f.mjs b/fjs/emergent_testing/proof.f.mjs index ed96a5f7d..ae074d667 100644 --- a/fjs/emergent_testing/proof.f.mjs +++ b/fjs/emergent_testing/proof.f.mjs @@ -13,8 +13,8 @@ import { log } from '../effects/node/module.f.mjs' import { defaultNodeProgramOptions, emptyState, virtual } from '../effects/node/virtual/module.f.mjs' import { assert, assertEq, todo } from '../asserts/module.f.mjs' import { - testAll, errorDetails, fmtPath, fmtTerm, fmtImport, ghEscape, isInteger, isIdentifier, - registerModule, parseTestSet, testResult, + testAll, fmtPath, fmtTerm, fmtImport, ghEscape, isInteger, isIdentifier, + registerModule, parseTestSet, defaultTest, main, register, } from './module.f.mjs' import { run as mockRun } from '../effects/mock/module.f.mjs' @@ -597,64 +597,6 @@ export const helpers = { assertEq(ghEscape('a\r\nb'), 'a%0D%0Ab') assertEq(ghEscape('a%b:c,d'), 'a%25b%3Ac%2Cd') }, - errorDetails: { - // Read structurally rather than by `instanceof Error`, so an error from - // another realm still reports its own stack. - messageAndStack: () => { - const [message, stack] = errorDetails({ message: 'boom', stack: 'boom\n at x' }) - assertEq(message, 'boom') - assertEq(stack, 'boom\n at x') - }, - withoutStack: () => { - const [message, stack] = errorDetails({ message: 'no stack' }) - assertEq(message, 'no stack') - assertEq(stack, 'no stack') - }, - // A value carrying only a stack is still a failure description; the - // message it does not have reads as the absent value it is. - stackOnly: () => { - const [message, stack] = errorDetails({ stack: 'trace' }) - assertEq(message, 'undefined') - assertEq(stack, 'trace') - }, - // A thrown *function* is an object as far as this reading goes. - callable: () => { - const [message] = errorDetails(Object.assign(() => undefined, { message: 'fn' })) - assertEq(message, 'fn') - }, - plainValue: () => { - const [message, stack] = errorDetails('just text') - assertEq(message, 'just text') - assertEq(stack, 'just text') - }, - nullValue: () => { - assertEq(errorDetails(null)[0], 'null') - }, - }, - testResult: { - passed: () => { - const r = testResult('a.f.mjs', ['x'], { result: ok(1), duration: 2 }, false) - assertEq(r.module, 'a.f.mjs') - assertEq(r.path, '.x') - assertEq(r.status, 'passed') - assertEq(r.duration, 2) - assertEq(r.message, undefined) - }, - failed: () => { - const r = testResult('a.f.mjs', ['x'], { result: error(new Error('bad')), duration: 0 }, false) - assertEq(r.status, 'failed') - assertEq(r.message, 'bad') - }, - // `defaultTest` has already inverted a `throws` leaf, so an `error` here - // means it returned when it was expected to throw — named rather than - // described by whatever it happened to return. - expectedToThrow: () => { - const r = testResult('a.f.mjs', ['throw', 'x'], { result: error(7), duration: 0 }, true) - assertEq(r.status, 'failed') - assertEq(r.message, 'Expected the proof to throw') - assertEq(r.stack, '') - }, - }, parseTestSet: { nullReturnsEmpty: () => { const result = parseTestSet(false, null) diff --git a/fjs/emergent_testing/todo/browser-test-controls.md b/fjs/emergent_testing/todo/browser-test-controls.md index 41d4f2913..77c391782 100644 --- a/fjs/emergent_testing/todo/browser-test-controls.md +++ b/fjs/emergent_testing/todo/browser-test-controls.md @@ -66,5 +66,5 @@ module or a default query parameter. - [Browser testing](browser-testing.md) — the shared browser application and report contract. -- [`emergent_testing/browser`](../browser/module.f.mjs) — the pure application - the controls drive; runner state is already separate from DOM presentation. +- [Shared browser/console runner core](share-browser-console-runner.md) — future + separation of pure runner state from DOM controls. diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index a3f008bc0..d81dc562b 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -48,7 +48,7 @@ three independent test frameworks. eventual isolated browser-test application root ├── index.html ├── _browser-test-entry.mjs -├── fjs/emergent_testing/browser/module.mjs +├── fjs/emergent_testing/browser.mjs └── authored or copied .f.mjs / .mjs modules ``` @@ -147,9 +147,8 @@ workers, or visual regression testing. - [ ] Create the JavaScript-only application root with a generated entry module covering every accepted module. - [x] Implement the first browser-compatible emergent-test runner and report - API, and share its proof semantics with `fjs t`: both runners now walk - proof trees through `emergent_testing/module.f.mjs` and differ only in - their `Reporter` and their effect interpreter. + API; follow up by sharing its pure semantics with `fjs t` in + [share-browser-console-runner](share-browser-console-runner.md). - [x] Implement the HTML UI and integrate it into the FunctionalScript website. - [ ] Add shared controller code for preparation, serving, report validation, @@ -157,42 +156,14 @@ workers, or visual regression testing. - [ ] Implement `fjs browser-test` without any Playwright dependency. - [ ] Implement a Playwright Test adapter that dynamically resolves external `playwright/test` and reuses the shared controller. -- [ ] Run the same application in Chromium, Firefox, and WebKit. Check the - yield `all` uses to give the page a turn while it runs - (`MessageChannel`, `../../effects/browser/module.mjs`) behaves in each, - and whether `scheduler.yield()` is worth preferring where it exists. +- [ ] Run the same application in Chromium, Firefox, and WebKit. - [ ] Add the validation fixtures above; add CI only after proof bodies - demonstrably execute inside browsers. **That gate is now met** — the - unified runner was driven in Chromium over the generated page, 3435 proofs - linked and executed, so what still blocks a CI job is the controller - below, not evidence. Nothing in `.github/workflows/` starts a browser - today, and `npm run website` only *generates* the suite: it exits `0` with - a failing proof in the manifest, so the browser suite is not a gate - anywhere yet. -- [ ] Keep a module-loading failure's stack. This section requires failures to - retain "module path, test path, message, and stack", and a *proof* failure - does — but a **load** failure no longer does. Linking is an `Import` - effect now, and its failure is an `IoError`, which is `{ code?, message }`: - `toIoError` drops the stack, so the report shows `stack: ''` where the - deleted runner showed the loader's own frames, which are what name the - importing module and line for a broken graph. The fix is one additive - optional field, `stack?: string` on `IoErrorInfo` in - `../../effects/common/types.ts`, filled by `toIoError` and read by - `infrastructureResult`. `IoError`'s rationale for dropping it — "a stack, a - `cause`, and arbitrary own properties do not survive a wire hop" — is right - about the last two and wrong about a stack, which is a string. Note that - reading `.stack` is a user-observable operation on a hostile value, the - same exposure `toIoError` already has reading `.message`. -- [ ] Assert a floor on the number of proofs a run discovers. Nothing does - today, in any runner: a `collectTests` that silently skipped most leaves - would keep `fjs t` at exit `0`, and a suite that loses coverage cannot - report that it has. + demonstrably execute inside browsers. ### Related - [`.f.mjs` proof discovery and coverage](f-mjs-test-and-coverage.md) -- [Hostile thrown values and cross-realm promises](hostile-proof-values.md) -- [Browser timer precision](timer-precision.md) +- [Shared browser/console runner core](share-browser-console-runner.md) - [Explicit browser test controls](browser-test-controls.md) - [authored `.f.mjs` package support](../../ci/todo/f-mjs-package-support.md) - [project roadmap](../../../todo/plan/roadmap.md) diff --git a/fjs/emergent_testing/todo/hostile-proof-values.md b/fjs/emergent_testing/todo/hostile-proof-values.md index 1bda0e573..3bc46b646 100644 --- a/fjs/emergent_testing/todo/hostile-proof-values.md +++ b/fjs/emergent_testing/todo/hostile-proof-values.md @@ -5,40 +5,46 @@ ### Problem -Both runners now share one core (`../module.f.mjs`), so they also share two -weaknesses the core cannot fix on its own. Neither is reachable from ordinary -FunctionalScript, and both were reachable — and covered — by the browser runner -before it and `fjs t` were unified; unifying adopted `fjs t`'s semantics -deliberately, so this file is where the difference went rather than being -silently dropped. +The browser runner (`../browser.mjs`) defends against two things `fjs t` does +not, and neither is reachable from ordinary FunctionalScript. That asymmetry is +the point of this file: when the two runners are unified +([share the browser and console proof runners](share-browser-console-runner.md)), +the shared core has to have *one* answer for each of them, decided rather than +inherited twice. `fjs t` is the reference, so the honest reading is that these +are gaps in `fjs t` which the browser happened to cover — and closing them in +the shared core is the way to keep that coverage instead of losing it to a port. **A value that resists being read is not attributed to the test that produced -it.** Two shared functions read user-supplied values without a guard: the -`collectTests` traversal enumerates a returned proof tree, and `errorDetails` -reads `message`/`stack` and calls `String` on a thrown value. A throwing -accessor, a revoked `Proxy`, or a hostile `toString` panics through either, and -there is no `try`/`catch` in FunctionalScript for the core to catch it with. - -The browser adapter turns that panic into an `infrastructure-error` report -rather than leaving the page in `running`, so a run always terminates — but the -whole run is lost where the deleted runner lost one test, and `fjs t` still ends -with a stack trace and no summary. What is missing is *attribution*: naming the -leaf whose value could not be read, and continuing with the rest. - -**A promise from another realm is not awaited.** Both `sandbox` interpreters ask -`p instanceof Promise`, which is false for a promise built in an iframe, a -worker, or a `node:vm` context. Such a value is walked as an ordinary proof tree -instead, so a *rejected* cross-realm promise is reported as a pass. The obvious -repair — brand-checking with `Object.prototype.toString` — is not one: the tag -is settable through `Symbol.toStringTag`, and an object carrying a `then` proof +it.** Two functions in the shared core read user-supplied values without a +guard: the `collectTests` traversal enumerates a returned proof tree, and +`errorDetails` reads `message`/`stack` and calls `String` on a thrown value. A +throwing accessor, a revoked `Proxy`, or a hostile `toString` panics through +either, and there is no `try`/`catch` in FunctionalScript for the core to catch +it with. `fjs t` ends with a stack trace and no summary; the browser runner +today loses one test and carries on. What is missing from the core is +*attribution*: naming the leaf whose value could not be read, and continuing +with the rest. Whichever runner ends up on top of it, a page left in `running` +or a process that exits with no summary is the outcome an automated controller +cannot act on. + +**A promise from another realm is not awaited.** `fjs t`'s `sandbox` asks `p +instanceof Promise`, which is false for a promise built in an iframe, a worker, +or a `node:vm` context. Such a value is walked as an ordinary proof tree +instead, so a *rejected* cross-realm promise is reported as a pass. The browser +runner carries `Symbol.species` machinery against this, which is a second answer +to the same question and is studied in +[imports, promises and realms](imports-promises-realms.md). The obvious repair — +brand-checking with `Object.prototype.toString` — is not one: the tag is +settable through `Symbol.toStringTag`, and an object carrying a `then` proof would then be assimilated, breaking the rule that only actual promises are asynchronous values. ### Design: a `catch` operation Reading a user value belongs to the *operation*, not to the shared core, which -is what makes one fix serve every runner. Since the two runners are now one, -guarding the traversal once covers `fjs t` and the browser together. +is what makes one fix serve every runner. Once the two runners share a core, +guarding the traversal once covers `fjs t` and the browser together — which is +an argument for doing this *with* the sharing change rather than before it. **`sandbox` cannot hold it, and the reason is not the one it looks like.** Timing is not the obstacle: the sub-tree walk in `runModule` happens *after* the @@ -61,8 +67,8 @@ export type Catch = readonly['catch', (f: () => T) => OpResult = { - readonly log: (message: string) => Effect - readonly load: () => Effect - readonly import: (source: string) => Effect -} -``` - -`Reporter` is already this shape for one third of the job, so the question -is whether extending it beats adding operations, or whether the two are the -same thing written differently. - -Whichever is chosen, the test is concrete: adding a third host — an MCP server, -a worker, `fjs browser-test` — must not mean writing a fourth `main`. - -### Constraints - -- The shared semantics must not acquire terminal text or DOM: a `TestResult` - carries neither today and that is what lets both reporters render it. -- A browser must not gain a `Write` or a `Program` it cannot honour. Lifting the - abstraction means finding the operation both hosts *can* implement, not giving - one a stub. - -### Tasks - -- [ ] Inventory what each host's `main` does that is not host-specific. -- [ ] Choose between artificial effects and injected verbs, and write down why. -- [ ] Express discovery once, so a manifest and a `readdir` walk are two - implementations of one operation rather than two programs. -- [ ] Express the outcome once, so an exit code and a report are two renderings - of one value. - -### Related - -- [Browser testing](browser-testing.md) — the hosts that are still to come. -- [Test-runner behavior](661-test-runner-behavior.md) — the differences between - runners that are intentional, and must stay intentional. diff --git a/fjs/emergent_testing/todo/timer-precision.md b/fjs/emergent_testing/todo/timer-precision.md index b09ff1eb2..edb330b27 100644 --- a/fjs/emergent_testing/todo/timer-precision.md +++ b/fjs/emergent_testing/todo/timer-precision.md @@ -5,8 +5,7 @@ ### Problem -`sandbox` measures every proof the same way in every host — read the clock, -run the body, read it again: +`sandbox` measures a proof by reading the clock either side of the body: ```js const before = performance.now() @@ -34,10 +33,10 @@ built by summing thousands of such rows accumulates the rounding rather than cancelling it, so the sum can be off by a large multiple in either direction depending on which way each read rounded. -Note this is not the same concern as -[`now`'s monotonicity](../../effects/browser/module.mjs), which is already -handled: `performance.timeOrigin + performance.now()` cannot go backwards. A -monotonic clock can still be a coarse one, and this is about the resolution. +Note this is not the same concern as monotonicity. `performance.now()` cannot +go backwards, which is why it is the right clock for a duration; a wall clock +would be worse. A monotonic clock can still be a coarse one, and this is about +the resolution. ### Preliminary design @@ -71,9 +70,10 @@ before changing the measurement. ### Constraints - `sandbox` is the operation that executes a proof body, and both runners must - agree on it exactly or a suite means different things in different hosts. - Any change to how it measures is a change to the shared contract, not a - browser-local tweak. + agree on it exactly or a suite means different things in different hosts. Any + change to how it measures is a change for both, not a browser-local tweak — + and it is very likely `fjs t` has a milder version of the same problem, since + a coarse clock is only easier to notice in a browser. - The clock must stay monotonic. Whatever replaces or supplements `performance.now()` cannot reintroduce wall-clock time. - A duration must not cost a second `sandbox` call or an extra scheduling @@ -99,5 +99,6 @@ before changing the measurement. report contract these durations belong to. - [Report a test's name before running it](report-before-running.md) — the other thing wrong with what a row shows. -- [Share the whole runner](share-the-whole-runner.md) — `sandbox` is shared, - so this is one decision, not two. +- [Share the browser and console proof runners](share-browser-console-runner.md) + — `sandbox` is the operation that executes a proof body in both hosts, so its + measurement is one decision, not two. diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index 68d18bb58..a3274ae6a 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -5,7 +5,7 @@ */ import type { Effect, Operation } from '../effects/types.ts' -import type { IoChannel, OpResult, SandboxResult } from '../effects/common/types.ts' +import type { IoChannel, SandboxResult } from '../effects/node/types.ts' /** A zero-argument test function whose return value may contain sub-tests. */ export type TestFn = () => unknown @@ -68,47 +68,6 @@ export type Reporter = { readonly test: (file: string, path: Path, set: TestEntry) => Effect, IoChannel> } -/** How a leaf test ended. */ -export type TestStatus = 'passed' | 'failed' - -/** - * One leaf result, normalized: which module it came from, the property chain - * that names it, how it ended, and how long it took. A failure also carries the - * message and stack it should be reported by. - * - * **It carries no terminal text and no DOM.** This is what a runner *observes*, - * so every reporter can render it its own way — coloured lines on a TTY, a - * `::error` annotation on GitHub, a list item in a page — and an automated - * consumer can read it off the wire. `path` is already rendered by - * {@link fmtPath} rather than left as a `Path`, because the chain is what a - * reader identifies the test by and nothing downstream walks it again. - */ -export type TestResult = { - readonly module: string - readonly path: string - readonly status: TestStatus - readonly duration: number - readonly message?: string - readonly stack?: string -} - -/** - * Records one normalized leaf result the moment it lands. - * - * It is an *operation* rather than a value threaded through the run because the - * results arrive concurrently: `all` performs a module's leaves at once, so a - * read-modify-write over shared memory would interleave and lose them. A - * runner's handler appends in one step, and {@link Reported} reads the whole - * sequence back once the run is over. - */ -export type Report = readonly['report', (result: TestResult) => OpResult] - -/** Every result {@link Report} has recorded, in the order they landed. */ -export type Reported = readonly['reported', () => OpResult] - -/** The pair of operations a recording runner implements. */ -export type ReportOp = Report | Reported - /** @internal */ export type _TestState = { readonly time: number, diff --git a/fjs/website/module.f.mjs b/fjs/website/module.f.mjs index f18dd4437..24d569c1d 100644 --- a/fjs/website/module.f.mjs +++ b/fjs/website/module.f.mjs @@ -49,7 +49,7 @@ pre { white-space: pre-wrap } ['script', { type: 'module', src: './_browser-test-entry.mjs' }] ) -const entry = utf8(`import { startBrowserTestSources } from './fjs/emergent_testing/browser/module.mjs' +const entry = utf8(`import { startBrowserTestSources } from './fjs/emergent_testing/browser.mjs' import { browserProofSources } from './fjs/emergent_testing/_browser-suite.mjs' const root = /** @type {Element} */ (document.querySelector('[data-browser-tests]')) diff --git a/fjs/website/todo/generate-website.md b/fjs/website/todo/generate-website.md index 59bb1817c..959fd8231 100644 --- a/fjs/website/todo/generate-website.md +++ b/fjs/website/todo/generate-website.md @@ -12,4 +12,4 @@ - [x] Browser test runner and proof-result UI - [ ] Move browser-manifest preparation into the website `NodeProgram` through Node effects, as designed in - [website-preparation-program](website-preparation-program.md) + [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) diff --git a/fjs/website/todo/website-preparation-program.md b/fjs/website/todo/website-preparation-program.md deleted file mode 100644 index 95abebc68..000000000 --- a/fjs/website/todo/website-preparation-program.md +++ /dev/null @@ -1,69 +0,0 @@ -## Own the browser-suite preparation from the website `NodeProgram` - -**Priority:** P3 -**Status:** open - -### Problem - -`npm run website` runs `fjs/website/browser-prepare.mjs`, an impure Node script -that is a second application entry point beside the FunctionalScript program in -`fjs/website/module.f.mjs`. It walks the source tree, decides which proof -modules a browser can link, writes `fjs/emergent_testing/_browser-suite.mjs`, -and only then calls `run(main)` to emit the page. Everything it does — reading -directories, reading files, writing generated source — is expressible as Node -effects, so the split exists for no reason other than history, and the -preparation half is proved only through `browser-source.proof.mjs`'s unit tests -of the token scanner rather than end to end against the virtual filesystem. - -The [shared browser/console runner](../../emergent_testing/README.md) work that -this issue was carved out of is done: the browser and `fjs t` now run the same -proof semantics, so what is left here is the *build*, not the runner. - -### Preliminary design - -Restore the package command to the FunctionalScript entry point: - -```json -"website": "node ./fjs/module.mjs r ./fjs/website/module.f.mjs" -``` - -`fjs/website/module.f.mjs` must own proof discovery, manifest generation, and -HTML/entry generation as one `NodeProgram`. If preparation needs a Node -capability that the FunctionalScript program cannot currently express, add the -smallest operation to `fjs/effects/node/` and its real and virtual interpreters -instead of bypassing Effects. Existing `readdir`, `readFile`, and `writeFile` -operations should be reused where sufficient. - -`fjs/website/browser-source.mjs` — the token scanner answering "does this -module export `proof`?" and "which modules does it import?" — is already pure -and has no `try`/`catch` or regular expressions. Renaming it to `.f.mjs` and -proving it as authored FunctionalScript is the first step; the graph walk and -the blocker classification then move into the program beside it. - -### Constraints - -- Website build-time filesystem access must be expressed by the FunctionalScript - `NodeProgram` through Node effects; npm scripts must not run an impure helper - as a second application entry point. -- The generated manifest and page must stay byte-identical across the move, so - the change is provably a refactor. -- Do not restore the removed `index-html` alias. - -### Tasks - -- [ ] Rename `fjs/website/browser-source.mjs` to authored `.f.mjs` with a - co-located proof at full coverage. -- [ ] Move static proof discovery and `_browser-suite.mjs` generation into - `fjs/website/module.f.mjs`; extend `fjs/effects/node/` only for a concrete - missing capability and prove the real and virtual interpretations. -- [ ] Delete `fjs/website/browser-prepare.mjs` and make the sole `website` - command `node ./fjs/module.mjs r ./fjs/website/module.f.mjs`. -- [ ] Prove the generator end to end against the virtual filesystem: a module - whose graph reaches `node:` is skipped with its reason, one that does not - is emitted. - -### Related - -- [Generate website](generate-website.md) — the parent issue. -- [Browser testing](../../emergent_testing/todo/browser-testing.md) — the - browser-native application the manifest feeds. From fe112e84641d0aa6a605f32a225621f00a62afbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:52:47 +0000 Subject: [PATCH 029/370] DESIGN: state the sharing cycle as five steps, ending in "solve for both" The first draft of "Follow the example" read as a demand for identical behaviour, which is the wrong target: a browser has no stdout and a terminal has no DOM, so different APIs and wrappers around a shared core are the normal shape. It also understated the obligation that actually keeps two contexts together. Restates the principle as the cycle: share the code; adjust where the host requires it; document every difference that remains; open an issue for each problem the port revealed; solve each of those issues for every context at once. Differences are allowed -- undocumented ones are not, and a fix that lands in one context only is how the two drift apart again while hiding the finding from the older one. `share-browser-console-runner.md` follows the same wording, and its constraints now say that host APIs may differ freely, behaviour only for a written reason, and a fix for either runner lands in both. --- DESIGN.md | 77 +++++++++++-------- .../todo/report-before-running.md | 3 + .../todo/share-browser-console-runner.md | 44 +++++++---- 3 files changed, 75 insertions(+), 49 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 8567a7b96..46a3ea467 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -108,45 +108,56 @@ on top of the weaker design. existing module; create a new one only if no good fit exists. This is different from DRY extraction: it is always appropriate. - **Follow the example** — when the same capability already exists elsewhere, - match it before improving on it. See below. + share it first, document what still differs, and fix what you find in every + context at once. See below. - **Avoid side effects and mutability.** ### Follow the example When a capability already exists somewhere in the repository and is being brought to a second context — another host, another backend, another runner — -**the existing one is the specification.** Reproduce its behaviour first, -including the simplifications it made and the things it does not do. Only once -the second context matches the first is it worth asking whether either should -change. - -This is not the same as reusing code, and it is the part that is easy to skip -while believing the principle is satisfied. Sharing a module and then giving the -new context its own rules produces something that *looks* unified and is not: -two behaviours behind one name, which is worse than two implementations behind -two names, because nothing signals the difference. - -The rule has three consequences worth stating outright. - -**A difference has to be justified, not merely noticed.** "The new context can -do better here" is a reason to file an issue, not a reason to diverge inside a -port. The example may be simple *for a reason* that is not visible from inside -the new context — `fjs t` runs proofs one after another, and its report is -readable, attributable and reproducible because of it. - -**A problem the new context reveals is everyone's problem.** If porting exposes -that a measurement is inaccurate, that an error loses attribution, or that an -ordering is unspecified, then it was very likely already true of the example and -merely easier to see now. Fix it once, for both, as its own change — or record -it as an issue. Fixing it only in the new context leaves the two out of step and -hides the finding from the place that has had the defect longest. - -**Solve it for the shared code or not at all.** A workaround that lives in one -host is a fork with extra steps. Either the shared layer learns the answer, or -the issue stays open and honest. - -The order, then, is: reuse and match the example; land that; *then* take the -new problems one at a time, as changes that apply everywhere. +**the existing one is the specification.** The order of work is: + +1. **Share the code.** Take the existing implementation as the shared core. +2. **Adjust where the second context genuinely requires it.** Different hosts + have different APIs, and a wrapper or an adapter around the shared core is + the normal, expected shape. Two contexts may end up behaving slightly + differently for reasons their hosts impose. +3. **Document every difference that remains,** at the point where it is made. +4. **Open an issue for each problem the port revealed,** rather than fixing it + inside the port. +5. **Solve each issue for every context at once,** so they stay in sync. + +The cost of skipping a step is not paid where it is skipped. Steps 1–2 without +3–5 give something that *looks* unified and is not: two behaviours behind one +name, which is worse than two implementations behind two names, because nothing +signals the difference. + +The parts worth stating outright: + +**Differences are allowed; undocumented differences are not.** The goal is not +one identical behaviour — a browser has no stdout and a terminal has no DOM, and +pretending otherwise invents a host that does not exist. The goal is that every +difference is deliberate, written down, and traceable to the host that forced +it. "This context can do better here" is not such a reason: that is an +improvement, and improvements go through step 4. + +**Solve it for both, or for neither.** Once an issue from step 4 is picked up, +the fix lands in every context, in the same change. A fix in one context only is +how the two drift back apart, and it hides the finding from the place that has +had the defect longest — which is usually the older one. This is the step that +keeps the contexts in sync, and it is the one under time pressure to skip. + +**The example may be simple for a reason.** What looks like a gap from inside +the new context is often a decision made in the old one. Copy it first; if it +turns out to be wrong, it is wrong in both places and worth an issue that says +so. + +**Keep the port separate from everything it inspires.** Land the sharing change +on its own, with behaviour unchanged. Anything new — a different scheduling +policy, a better measurement, an extra guard — is its own change afterwards. +Combined, they cannot be reviewed: an argument about the new idea becomes an +argument about the port. ### Exception to DRY: performance measurement diff --git a/fjs/emergent_testing/todo/report-before-running.md b/fjs/emergent_testing/todo/report-before-running.md index 9d5cded82..37d8fff98 100644 --- a/fjs/emergent_testing/todo/report-before-running.md +++ b/fjs/emergent_testing/todo/report-before-running.md @@ -58,6 +58,9 @@ the shared core rather than twice. - Whatever is emitted has to be as useful to an automated consumer as to a reader — a start with no matching result is precisely the signal a crashed run leaves behind, and a controller should be able to read it. +- The start event lands in both runners in the same change. Their output differs + — a terminal line and a DOM row — but a runner that names a running test and + one that does not are two different tools. ### Tasks diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index f61d69fc7..d200a3c93 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -31,14 +31,20 @@ repository does not want to pay again, and the record of why is worth more than the code was. **The order of work is the deliverable here, not just the final shape.** See [DESIGN.md §4, "Follow the example"](../../../DESIGN.md). -**`fjs t` is the specification, including the things it does not do.** The -attempt shared the modules and then let the browser keep its own rules: its own -test-name format, its own scheduling policy, its own clock. That is the failure -mode to avoid, and it is easy to miss because it *looks* like success — one -module, one name, two behaviours behind it. Two implementations behind two names -are more honest than that, because nothing about the shared name signals the -difference. Sharing code and sharing behaviour are different achievements, and -only the second one is this issue. +**Share the code, then keep the two in sync.** The order is: share; adjust +where the host genuinely requires it; document every difference that remains; +open an issue for each problem the port revealed; and solve each of those issues +for **both** runners at once. The last step is the one that matters and the one +under pressure to skip. + +Differences are fine — a browser has no stdout, a terminal has no DOM, and the +two will use different APIs and wrappers around the same core. *Undocumented* +differences are not. The attempt shared the modules and then let the browser +keep its own test-name format, its own scheduling policy and its own clock, none +of which the host forced. That is the failure mode: it *looks* like success — +one module, one name — while two behaviours hide behind it, and two +implementations behind two names would have been more honest, because nothing +about the shared name signals the difference. **`fjs t` is sequential, and that is a decision to copy, not a gap to fill.** The attempt gave the browser a batch size — proofs launched in groups with a @@ -69,11 +75,11 @@ both are properly issues rather than fixes inside a port: [Hostile thrown values and cross-realm promises](hostile-proof-values.md) and [Imports, promises and realms](imports-promises-realms.md). -The rule that follows: **land the shared core matching `fjs t` exactly, then -take each new problem as its own change that applies everywhere.** A difference -between hosts is something to justify in an issue, not to introduce inside a -port. If the port cannot preserve a behaviour, that is a finding to record -before the port merges, not a silent divergence to explain in review. +The rule that follows: **land the shared core with behaviour unchanged, then +take each new problem as its own change that lands in both runners together.** +An improvement the browser could have is an issue, not something to introduce +inside a port. A behaviour the port cannot preserve is a finding to record +before it merges, not a silent divergence to explain in review. **Keep the change reviewable.** The attempt was 2646 insertions and 1408 deletions across 35 files in one PR — a move, a rewrite, a new effects layer, a @@ -174,9 +180,13 @@ are shared. - Preserve the recursive proof semantics and totals of `fjs t` exactly, including objects with a proof property named `then`; only actual promises are asynchronous values. -- Both runners must produce the same test name for the same leaf. -- Introduce no host-specific behaviour that `fjs t` does not already have. A - difference the port cannot avoid is recorded as an issue before it merges. +- Both runners must produce the same test name for the same leaf. This one is + not a host difference: nothing about a browser prevents it, and a divergence + here is the visible sign that the semantics underneath were never unified. +- Every remaining difference between the two runners is documented where it is + made, and traceable to something the host forced. Host APIs and wrappers may + differ freely; behaviour may differ only for a written reason. +- A fix for a problem either runner has lands in both, in the same change. - Browser modules must not import Node built-ins, the Node effect interpreter, `node:test`, or Playwright. - Website build-time filesystem access must be expressed by the FunctionalScript @@ -226,6 +236,8 @@ are shared. test counts, and normalized failures from the same fixtures. - [ ] Record every behaviour the browser file has today and the shared core will not keep, as an issue, before the sharing change merges. +- [ ] Close each of those issues for both runners at once, so the two stay in + sync rather than drifting from the day the core is shared. ### Related From b9b273c7234b751097ada1467a7631bfe96cb88f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:55:13 +0000 Subject: [PATCH 030/370] DESIGN: the shared thing is the skeleton, not just the outcome "Share the code" left open where the sharing stops, which is the question that actually decides whether two runners stay one. States it: what is shared is the skeleton -- the control flow, the order of operations, the decisions and their names. Every context runs that same skeleton, and differs only by supplying a different part at a place the skeleton names. That leaves exactly two ways to accommodate a context, both additive: change that context's part, or improve the skeleton so every context benefits. There is no third. A branch inside the skeleton asking which host it is running on is a fork wearing a shared name, and a need no existing part can express is a missing extension point rather than a special case. `share-browser-console-runner.md` names the runner's skeleton and its parts in those terms -- execute a leaf, report a result, link a module -- and gains a task to check that nothing host-specific is left outside one of them. --- DESIGN.md | 72 ++++++++++++------- .../todo/share-browser-console-runner.md | 57 +++++++++------ 2 files changed, 83 insertions(+), 46 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 46a3ea467..c224dbc3a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -107,46 +107,66 @@ on top of the weaker design. 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. -- **Follow the example** — when the same capability already exists elsewhere, - share it first, document what still differs, and fix what you find in every - context at once. See below. +- **Follow the example** — one skeleton for every context; differences live in + the parts it calls, and improvements go into the skeleton so everyone gets + them. See below. - **Avoid side effects and mutability.** ### Follow the example When a capability already exists somewhere in the repository and is being brought to a second context — another host, another backend, another runner — -**the existing one is the specification.** The order of work is: - -1. **Share the code.** Take the existing implementation as the shared core. -2. **Adjust where the second context genuinely requires it.** Different hosts - have different APIs, and a wrapper or an adapter around the shared core is - the normal, expected shape. Two contexts may end up behaving slightly - differently for reasons their hosts impose. -3. **Document every difference that remains,** at the point where it is made. +**the existing one is the specification.** + +What is shared is the **skeleton**: the control flow, the order of operations, +the decisions and their names — the shape of the whole thing. Every context runs +that same skeleton. Where a context differs, it differs by supplying a different +**part** that the skeleton calls out to, at a place the skeleton names. It does +not differ by having a skeleton of its own. + +So there are exactly two ways to accommodate a context, and both are additive: + +- **Adjust that context's part.** A browser writes rows into a DOM where a + terminal writes lines to stdout; those are two implementations of one named + part, and the skeleton above them cannot tell which it has. +- **Improve the skeleton, for everyone.** If what the new context needs is + something the skeleton should have had, put it there. Every context gets it, + and that is a feature of the change rather than a side effect to apologize + for. + +There is no third way. A branch inside the skeleton that asks which host it is +running on is a fork wearing a shared name, and it is worse than two honest +implementations, because nothing about the shared name signals the difference. A +context that cannot be served by any existing part means the skeleton is missing +an extension point: add the point — one more named part that every context then +supplies — rather than a special case. + +The order of work follows from that: + +1. **Share the skeleton.** Take the existing implementation as the core, with + its behaviour unchanged. +2. **Adjust the parts** the new context genuinely requires, or extend the + skeleton so it can express what the new context needs. +3. **Document every difference that remains,** at the part where it is made. 4. **Open an issue for each problem the port revealed,** rather than fixing it inside the port. -5. **Solve each issue for every context at once,** so they stay in sync. - -The cost of skipping a step is not paid where it is skipped. Steps 1–2 without -3–5 give something that *looks* unified and is not: two behaviours behind one -name, which is worse than two implementations behind two names, because nothing -signals the difference. +5. **Solve each issue in the skeleton or in every part at once,** so the + contexts stay in sync. The parts worth stating outright: **Differences are allowed; undocumented differences are not.** The goal is not one identical behaviour — a browser has no stdout and a terminal has no DOM, and pretending otherwise invents a host that does not exist. The goal is that every -difference is deliberate, written down, and traceable to the host that forced -it. "This context can do better here" is not such a reason: that is an -improvement, and improvements go through step 4. - -**Solve it for both, or for neither.** Once an issue from step 4 is picked up, -the fix lands in every context, in the same change. A fix in one context only is -how the two drift back apart, and it hides the finding from the place that has -had the defect longest — which is usually the older one. This is the step that -keeps the contexts in sync, and it is the one under time pressure to skip. +difference lives in a named part, is deliberate, and is traceable to something +the host forced. "This context could do better here" is not such a reason: that +is an improvement, and an improvement belongs in the skeleton, where everyone +gets it. + +**Solve it for every context, or for none.** Once an issue from step 4 is picked +up, the fix lands everywhere in the same change. A fix in one context only is how +the contexts drift back apart, and it hides the finding from the place that has +had the defect longest — usually the older one. **The example may be simple for a reason.** What looks like a gap from inside the new context is often a decision made in the old one. Copy it first; if it diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index d200a3c93..5e3cc0e56 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -31,17 +31,26 @@ repository does not want to pay again, and the record of why is worth more than the code was. **The order of work is the deliverable here, not just the final shape.** See [DESIGN.md §4, "Follow the example"](../../../DESIGN.md). -**Share the code, then keep the two in sync.** The order is: share; adjust -where the host genuinely requires it; document every difference that remains; -open an issue for each problem the port revealed; and solve each of those issues -for **both** runners at once. The last step is the one that matters and the one -under pressure to skip. - -Differences are fine — a browser has no stdout, a terminal has no DOM, and the -two will use different APIs and wrappers around the same core. *Undocumented* -differences are not. The attempt shared the modules and then let the browser -keep its own test-name format, its own scheduling policy and its own clock, none -of which the host forced. That is the failure mode: it *looks* like success — +**One skeleton, with named parts.** The thing to share is the *runner itself*: +the order in which modules are linked, leaves discovered, bodies executed, +throws inverted, results counted and the run concluded. Both hosts run that same +skeleton. Everything host-specific is a **part** the skeleton calls at a place it +names — where the leaf body is executed, where a result is reported, where a +module is linked — and a part is where a browser is allowed to be a browser. + +That gives exactly two ways to accommodate a host, both additive: change *that +host's part*, or *improve the skeleton so every host benefits*. There is no +third. A branch inside the skeleton asking which host it is running on is a fork +wearing a shared name. A host need that no existing part can express means the +skeleton is missing an extension point — add the point, which every host then +supplies, rather than a special case. + +Differences between the parts are fine and expected: a DOM row and a terminal +line are two implementations of the same named part, and the skeleton above them +cannot tell which it has. *Undocumented* differences are not. The attempt shared +the modules and then let the browser keep its own test-name format, its own +scheduling policy and its own clock — none of which its host forced, and none of +which belonged in a part. That is the failure mode: it *looks* like success — one module, one name — while two behaviours hide behind it, and two implementations behind two names would have been more honest, because nothing about the shared name signals the difference. @@ -75,11 +84,12 @@ both are properly issues rather than fixes inside a port: [Hostile thrown values and cross-realm promises](hostile-proof-values.md) and [Imports, promises and realms](imports-promises-realms.md). -The rule that follows: **land the shared core with behaviour unchanged, then -take each new problem as its own change that lands in both runners together.** -An improvement the browser could have is an issue, not something to introduce -inside a port. A behaviour the port cannot preserve is a finding to record -before it merges, not a silent divergence to explain in review. +The rule that follows: **land the shared skeleton with behaviour unchanged, then +take each new problem as its own change — in the skeleton where it belongs +there, so both runners get it, or in every part at once.** An improvement the +browser could have is an issue, not something to introduce inside a port. A +behaviour the port cannot preserve is a finding to record before it merges, not +a silent divergence to explain in review. **Keep the change reviewable.** The attempt was 2646 insertions and 1408 deletions across 35 files in one PR — a move, a rewrite, a new effects layer, a @@ -183,10 +193,14 @@ are shared. - Both runners must produce the same test name for the same leaf. This one is not a host difference: nothing about a browser prevents it, and a divergence here is the visible sign that the semantics underneath were never unified. -- Every remaining difference between the two runners is documented where it is - made, and traceable to something the host forced. Host APIs and wrappers may - differ freely; behaviour may differ only for a written reason. -- A fix for a problem either runner has lands in both, in the same change. +- The skeleton never asks which host it is running on. Anything host-specific is + a part it calls; anything it cannot express through a part is a missing + extension point, not a special case. +- Every remaining difference between the two runners lives in a part, is + documented there, and is traceable to something the host forced. Host APIs and + wrappers may differ freely; behaviour may differ only for a written reason. +- A fix for a problem either runner has lands in the skeleton, or in every part + at once — in the same change. - Browser modules must not import Node built-ins, the Node effect interpreter, `node:test`, or Playwright. - Website build-time filesystem access must be expressed by the FunctionalScript @@ -210,6 +224,9 @@ are shared. - [ ] Inventory duplicated semantics in `emergent_testing/module.f.mjs` and `emergent_testing/browser.mjs`, and define the smallest shared API. +- [ ] Name the skeleton's parts explicitly — execute a leaf, report a result, + link a module — and check that nothing host-specific is left outside one + of them. - [ ] Make the existing `collectTests`/path behavior the single source of truth for console and browser execution. - [ ] Share the test-name format, and prove both runners name the same leaf From 2250a4aea63cdecff68fff011e2fba28b3d2d849 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 11:13:19 +0000 Subject: [PATCH 031/370] emergent_testing: name a browser test the way `fjs t` does First step of `todo/share-browser-console-runner.md`, which is now sequenced into seven of them, one per pull request. The reverted attempt did the whole issue at once, and that is why an argument about scheduling became an argument about the port. The page named a test `${module} ${path}` -- `./a.f.mjs .x` -- where the console runner prints `import("./a.f.mjs").proof.x()`. Both already share `collectTests` and `fmtPath`, so the traversal agreed while the two reports could not be compared, which is the visible half of the runners having drifted apart. Results now carry a `name` built by `fmtImport`, the function `fjs t` formats its own result lines with, and the page renders that. `nameMatchesTheConsoleRunner` asserts against the function rather than against a spelling, so the two cannot drift again without failing. `name` is a field rather than something the renderer derives, because `module` and `path` do not always recombine into one: a module that cannot be enumerated and a proof exported as a bare function both carry an empty `path`, and only the code that produced the result knows which it had. A module-level failure is named by its source, which is what is known about it. `path` stays for the consumers that read it, and is now redundant for every leaf -- recorded in the todo as part of the report shape rather than changed here. Changelog: - `emergent_testing`: the browser report's results carry a `name` -- the test identity `fjs t` prints, built by the same `fmtImport` function -- and the page renders it, so both runners spell a test identically --- changelog/unreleased/1737.md | 3 ++ fjs/emergent_testing/browser.mjs | 37 +++++++++++++---- fjs/emergent_testing/browser/proof.mjs | 23 ++++++++++- .../todo/share-browser-console-runner.md | 41 ++++++++++++++++++- 4 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 changelog/unreleased/1737.md diff --git a/changelog/unreleased/1737.md b/changelog/unreleased/1737.md new file mode 100644 index 000000000..9f39ab1eb --- /dev/null +++ b/changelog/unreleased/1737.md @@ -0,0 +1,3 @@ +- `emergent_testing`: the browser report's results carry a `name` — the test + identity `fjs t` prints, built by the same `fmtImport` function — and the page + renders it, so both runners spell a test identically diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 3d280f3bf..6d6b3a1e4 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -18,7 +18,7 @@ * @import { _TestAndPath } from './types.ts' */ -import { collectTests, fmtPath } from './module.f.mjs' +import { collectTests, fmtImport, fmtPath } from './module.f.mjs' /** @type {(value: unknown) => string} */ const text = value => { @@ -57,7 +57,25 @@ const errorDetails = error => { return [fallback, fallback] } -/** @typedef {{ readonly module: string, readonly path: string, readonly status: string, readonly duration: number, readonly message?: string, readonly stack?: string }} _BrowserTestResult */ +/** + * `name` is the test's identity, and it is deliberately not built here: it comes + * from `fmtImport`, the same function `fjs t` prints its result lines with, so + * the two runners name a leaf identically — + * `import("./a.proof.f.mjs").proof.x()` in both. A page that invented its own + * spelling would produce reports that cannot be diffed against the console + * runner's, which is the visible half of the two runners having drifted apart. + * + * It is a field rather than something the renderer derives, because `module` + * and `path` cannot always be recombined into one: a module-level failure and a + * proof exported as a bare function both carry an empty `path`, and only the + * code that produced the result knows which it had. + * + * `path` stays for the consumers that already read it. It is now redundant with + * `name` for every leaf, and belongs in the report-shape decision this issue's + * todo tracks rather than in this change. + * + * @typedef {{ readonly module: string, readonly path: string, readonly name: string, readonly status: string, readonly duration: number, readonly message?: string, readonly stack?: string }} _BrowserTestResult + */ /** @typedef {{ readonly status: string, readonly browser: string, readonly totals: { readonly tests: number, readonly passed: number, readonly failed: number }, readonly duration: number, readonly results: readonly _BrowserTestResult[] }} BrowserTestReport */ /** @@ -182,11 +200,12 @@ const runPromise = (value, fulfilled, rejected) => { /** @type {(module: string, path: readonly (string | null)[], throws: boolean, fn: () => unknown, result: (result: _BrowserTestResult) => void) => Promise} */ const runOne = (module, path, throws, fn, result) => { const start = performance.now() + const name = fmtImport(module, path) /** @type {(value: unknown) => Promise | readonly _BrowserTestResult[]} */ const passed = value => { const duration = performance.now() - start if (throws) { - const failure = { module, path: fmtPath(path), status: 'failed', duration, + const failure = { module, path: fmtPath(path), name, status: 'failed', duration, message: 'Expected the proof to throw', stack: '' } result(failure) return [failure] @@ -205,7 +224,7 @@ const runOne = (module, path, throws, fn, result) => { return Promise.all(children.map(([childPath, child]) => runOne(module, childPath, child.throws, child.fn, result) )).then(results => { - const success = { module, path: fmtPath(path), status: 'passed', duration } + const success = { module, path: fmtPath(path), name, status: 'passed', duration } result(success) return [success, ...results.flat()] }) @@ -214,12 +233,12 @@ const runOne = (module, path, throws, fn, result) => { const failed = error => { const duration = performance.now() - start if (throws) { - const success = { module, path: fmtPath(path), status: 'passed', duration } + const success = { module, path: fmtPath(path), name, status: 'passed', duration } result(success) return [success] } const [message, stack] = errorDetails(error) - const failure = { module, path: fmtPath(path), status: 'failed', duration, message, stack } + const failure = { module, path: fmtPath(path), name, status: 'failed', duration, message, stack } result(failure) return [failure] } @@ -265,7 +284,7 @@ export const runBrowserProofs = (modules, result = () => undefined) => { /** @type {(module: string, error: unknown) => () => Promise} */ const unreadable = (module, error) => () => { const [message, stack] = errorDetails(error) - const failure = { module, path: '', status: 'failed', duration: 0, message, stack } + const failure = { module, path: '', name: module, status: 'failed', duration: 0, message, stack } announce(failure) return Promise.resolve([failure]) } @@ -372,7 +391,7 @@ export const startBrowserTestSources = (root, sources, importer) => { return publish(root, Promise.resolve(reportOf('infrastructure-error', duration, rejected.map(({ source, error }) => { const [message, stack] = errorDetails(error) - return { module: source, path: '', status: 'failed', duration, message, stack } + return { module: source, path: '', name: source, status: 'failed', duration, message, stack } })))) } return startBrowserTests(root, loadedModules.flatMap(module => @@ -431,7 +450,7 @@ const renderResult = (document, result) => { const item = document.createElement('li') item.setAttribute('data-status', result.status) const detail = result.status === 'failed' ? `: ${result.message}\n${result.stack}` : '' - item.textContent = `${result.status === 'passed' ? 'PASS' : 'FAIL'} ${result.module} ${result.path} (${result.duration.toFixed(1)} ms)${detail}` + item.textContent = `${result.status === 'passed' ? 'PASS' : 'FAIL'} ${result.name} (${result.duration.toFixed(1)} ms)${detail}` return item } diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index c4af06c77..d0230ec1e 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -11,6 +11,7 @@ import { runInNewContext } from 'node:vm' import { assert, assertEq, assertNotNullish, assertStructurallySame } from '../../asserts/module.f.mjs' import { renderBrowserReport, runBrowserProofs, startBrowserTests, startBrowserTestSources } from '../browser.mjs' +import { fmtImport } from '../module.f.mjs' /** @typedef {{ readonly tag: string, attributes: ReadonlyMap, readonly ownerDocument: _Document, textContent: string, children: readonly _Element[], readonly setAttribute: (name: string, value: string) => void, readonly removeAttribute: (name: string) => void, readonly querySelector: (selector: string) => _Element | null, readonly replaceChildren: (...nodes: readonly _Element[]) => void, readonly append: (node: _Element) => void }} _Element */ /** @typedef {{ defaultView: _View | null, readonly createElement: (tag: string) => _Element }} _Document */ @@ -106,6 +107,24 @@ export const proof = { const report = await run({ 'a.b': () => undefined }) assertEq(report.results[0]?.path, '["a.b"]') }, + // The page and `fjs t` must name a leaf identically, or two reports of the + // same suite cannot be compared. Asserting against `fmtImport` — the + // function the console runner prints its result lines with — is what makes + // that a shared fact rather than two spellings that happen to agree today. + nameMatchesTheConsoleRunner: async () => { + const report = await run({ nested: () => ({ child: () => undefined }) }) + assertEq(report.results[0]?.name, fmtImport('proof', ['nested'])) + assertEq(report.results[1]?.name, fmtImport('proof', ['nested', null, 'child'])) + assertEq(report.results[1]?.name, 'import("proof").proof.nested().child()') + }, + // A module that cannot be enumerated has no leaf to name, and an empty + // `path` does not distinguish it from a proof exported as a bare function. + // The module is what is known, so the module is the name. + unreadableModuleIsNamedByItsSource: async () => { + const report = await run(new Proxy({}, { ownKeys: () => { throw 'hostile' } })) + assertEq(report.status, 'failed') + assertEq(report.results[0]?.name, 'proof') + }, arbitraryThrow: async () => { const report = await run({ fail: () => { throw Object.create(null) } }) assertEq(report.status, 'failed') @@ -295,10 +314,10 @@ export const proof = { browser: 'test', totals: { tests: 1, passed: 1, failed: 0 }, duration: 1, - results: [{ module: 'm', path: '.t', status: 'passed', duration: 0.5 }], + results: [{ module: 'm', path: '.t', name: 'import("m").proof.t()', status: 'passed', duration: 0.5 }], }) assertEq(p.summary.textContent, '1 passed, 0 failed (1.0 ms)') - assertEq(p.results.children[0]?.textContent, 'PASS m .t (0.5 ms)') + assertEq(p.results.children[0]?.textContent, 'PASS import("m").proof.t() (0.5 ms)') }, sources: async () => { const p = page() diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 5e3cc0e56..8fec4b1ee 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -99,6 +99,40 @@ it: the shared semantics first, with `fjs t` unchanged in behaviour and the browser file only calling into it; the layout moves after; anything genuinely new last, on its own. +### Steps + +**One step per pull request.** The reverted attempt did the whole issue at once +— 2646 insertions and 1408 deletions across 35 files — and that is why its +arguments could not be separated: a question about scheduling became a question +about the port. Each step below stands on its own, leaves both runners working, +and is reviewable without the next one. + +- [x] **1. One name function.** The page names a leaf with `fmtImport`, the + function `fjs t` prints its result lines with, so the two runners spell a + test identically. This is the smallest possible piece of the issue and + also its most visible symptom. +- [ ] **2. One `sandbox`.** Executing a proof body — the clock either side, the + `try`/`catch`, and the rule that only an actual `Promise` is awaited — is + the operation both runners must agree on exactly, and the one place where + they currently do not. Decide the cross-realm question + ([imports, promises and realms](imports-promises-realms.md)) as part of + it, or record the decision, but do not let a port make it silently. +- [ ] **3. Common effects.** Move the host-independent operations (`all`, + `await`, `fetch`, `import`, `now`, `sandbox`) out of `effects/node` into a + shared module that `effects/node` re-exports unchanged, so nothing has to + move with them. +- [ ] **4. A browser interpreter** for exactly those operations, with no + scheduling policy of its own. +- [ ] **5. One reporter.** A normalized result the page and the terminal both + render, with no DOM and no terminal text in it. +- [ ] **6. One skeleton.** The page's proof-tree walk is deleted and the shared + traversal runs it. +- [ ] **7. The layout move**, and the website preparation program. + +Steps 2 and 6 are the ones that change behaviour, so they are the ones to keep +smallest. Anything a step reveals goes to an issue and is fixed for both runners +later, never inside the step. + ### Preliminary design Share semantics, not host mechanics. The console runner should keep using the @@ -229,8 +263,11 @@ are shared. of them. - [ ] Make the existing `collectTests`/path behavior the single source of truth for console and browser execution. -- [ ] Share the test-name format, and prove both runners name the same leaf - identically. +- [x] Share the test-name format, and prove both runners name the same leaf + identically. The browser report carries a `name` built by `fmtImport`, and + `nameMatchesTheConsoleRunner` pins it to that function rather than to a + spelling. Its `path` field is now redundant with `name` for every leaf and + should go when the report shape is decided. - [ ] Define normalized leaf, progress, infrastructure-error, totals, and report values without terminal or DOM fields. - [ ] Decide whether browser import/time/yield/publication justify From 03b3b06cf725f1a3dceb3e0d865215cc42f2eb9c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 11:14:52 +0000 Subject: [PATCH 032/370] changelog: rename the entry to this PR's number --- changelog/unreleased/{1737.md => 1738.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/unreleased/{1737.md => 1738.md} (100%) diff --git a/changelog/unreleased/1737.md b/changelog/unreleased/1738.md similarity index 100% rename from changelog/unreleased/1737.md rename to changelog/unreleased/1738.md From e37271e22e51c2ff5e84010d2efc5154cbc85149 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 07:28:06 -0700 Subject: [PATCH 033/370] how --- fjs/emergent_testing/module.f.mjs | 16 ---------------- fjs/emergent_testing/proof.f.mjs | 10 +--------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index a32868040..59a96998a 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -330,22 +330,6 @@ export const fmtPath = path => export const fmtImport = (file, path) => `import(${JSON.stringify(file)}).proof${fmtPath(path)}()` -/** - * Renders a key chain for terminal output: `| ` per level of depth, followed - * by the last segment formatted as a bare integer, a bare identifier, or a - * JSON-quoted string. E.g. `['math', 'add']` → `| | add`, - * `['a', '0']` → `| | 0`, `['x', 'hello world']` → `| | "hello world"`. - * - * @type {(path: Path) => string} - */ -export const fmtTerm = path => { - const keys = path.flatMap(k => k !== null ? [k] : []) - const indent = '| '.repeat(keys.length) - if (keys.length === 0) { return `${indent}()` } - const last = keys[keys.length - 1] - return `${indent}${isInteger(last) || isIdentifier(last) ? last : JSON.stringify(last)}` -} - /** * Percent-encodes characters that GitHub workflow-command property values * treat as separators (`%`, `:`, `,`) plus newlines. diff --git a/fjs/emergent_testing/proof.f.mjs b/fjs/emergent_testing/proof.f.mjs index ae074d667..456c5b022 100644 --- a/fjs/emergent_testing/proof.f.mjs +++ b/fjs/emergent_testing/proof.f.mjs @@ -13,7 +13,7 @@ import { log } from '../effects/node/module.f.mjs' import { defaultNodeProgramOptions, emptyState, virtual } from '../effects/node/virtual/module.f.mjs' import { assert, assertEq, todo } from '../asserts/module.f.mjs' import { - testAll, fmtPath, fmtTerm, fmtImport, ghEscape, isInteger, isIdentifier, + testAll, fmtPath, fmtImport, ghEscape, isInteger, isIdentifier, registerModule, parseTestSet, defaultTest, main, register, } from './module.f.mjs' @@ -582,14 +582,6 @@ export const helpers = { assertEq(fmtPath(['x', 'hello world']), '.x["hello world"]') assertEq(fmtPath(['outer', null, 'inner']), '.outer().inner') }, - fmtTerm: () => { - assertEq(fmtTerm([]), '()') - assertEq(fmtTerm(['math', 'add']), '| | add') - assertEq(fmtTerm(['a', '0']), '| | 0') - assertEq(fmtTerm(['x', 'hello world']), '| | "hello world"') - // null marks a function-call boundary; fmtTerm filters it out - assertEq(fmtTerm(['outer', null, 'inner']), '| | inner') - }, ghEscape: () => { assertEq(ghEscape('a%b'), 'a%25b') assertEq(ghEscape('a:b'), 'a%3Ab') From a9f175cb253ec1eed8703810b975216c4a942998 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 14:44:53 +0000 Subject: [PATCH 034/370] todo: an index.html per module directory; note what a module key is relative to `directory-index-pages.md`: the generated site is one page, while the repository is a tree of directories that each hold a module, its types, its proofs and its todos -- none of it reachable from the site. A generated `index.html` beside every `module.f.mjs` catalogues its files, subdirectories, `todo/` issues and local proofs, and runs those proofs through the existing browser runner with the manifest narrowed to that directory. It is not a second runner, and it is a second consumer of the traversal the website program already performs. Also records, in `share-browser-console-runner.md`, that a test name embeds a module key and a module key is relative to the root a run was given: `fjs t` in `fjs/types/list` names a leaf `import("./proof.f.mjs")...` where the same leaf from the repository root is `import("./fjs/types/list/proof.f.mjs")...`. That is `fjs t` differing from itself across roots rather than the two runners differing, and it is deliberate -- but two reports only compare when their roots agree, so which root a report declares belongs to the report-shape decision. --- .../todo/share-browser-console-runner.md | 9 ++ fjs/website/todo/directory-index-pages.md | 101 ++++++++++++++++++ fjs/website/todo/generate-website.md | 3 + 3 files changed, 113 insertions(+) create mode 100644 fjs/website/todo/directory-index-pages.md diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 8fec4b1ee..a5b619bef 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -227,6 +227,15 @@ are shared. - Both runners must produce the same test name for the same leaf. This one is not a host difference: nothing about a browser prevents it, and a divergence here is the visible sign that the semantics underneath were never unified. + Note that a name embeds a *module key*, and a module key is relative to the + root a run was given: `fjs t` invoked in `fjs/types/list` names a leaf + `import("./proof.f.mjs")...` where the same leaf from the repository root is + `import("./fjs/types/list/proof.f.mjs")...`. That is `fjs t` differing from + itself across roots, not the two runners differing, and it is deliberate — a + subtree run reports a subtree. But two reports are only comparable when their + roots agree, and once the browser suite is a gate the question of which root a + report declares is worth settling. It belongs to the report shape, with + `path`. - The skeleton never asks which host it is running on. Anything host-specific is a part it calls; anything it cannot express through a part is a missing extension point, not a special case. diff --git a/fjs/website/todo/directory-index-pages.md b/fjs/website/todo/directory-index-pages.md new file mode 100644 index 000000000..c5d256987 --- /dev/null +++ b/fjs/website/todo/directory-index-pages.md @@ -0,0 +1,101 @@ +## An `index.html` for every module directory + +**Priority:** P3 +**Status:** open + +### Problem + +The generated website is one page. The repository it describes is a tree of +directories, most of which hold a `module.f.mjs`, its `types.ts`, a +`proof.f.mjs`, a `todo/` folder, and some subdirectories — and none of that is +reachable from the site. A reader who wants to know what `fjs/types/list` *is* +reads the source on GitHub; a reader who wants to know whether its proofs pass +runs the whole suite. Neither is a fact the website carries, and both are facts +it already has everything it needs to produce. + +Browsing is the missing half. `fjs t` answers "did everything pass" and the +browser suite answers "does everything pass in a browser", but no view answers +"what is in this directory, and what does it prove?" — which is the question a +newcomer, and a maintainer looking at an unfamiliar corner, both start from. + +### Preliminary design + +For every directory containing a `module.f.mjs` (and, after stage 2 of +[`migrate-typescript-to-mjs`](../../../todo/migrate-typescript-to-mjs.md), an +authored `module.f.js`), generate an `index.html` next to it in the output tree. +Each page is a catalog of that directory: + +- **Files** — the modules, their `types.ts`, proofs and `README.md`, each linked + to a rendered source view where one exists. `README.md` conversion is already + on [generate-website](generate-website.md); this is a consumer of it. +- **Subdirectories** — linked to their own `index.html`, so the tree is + walkable in both directions. Include a breadcrumb back to the root. +- **Local proofs** — the tests this directory's modules contribute, named the + way both runners name them (`fmtImport`, `emergent_testing/module.f.mjs`), and + runnable *here*: the browser runner already takes a list of proof sources, so + a directory page is that same application with the manifest narrowed to this + directory. That is the interesting part of this issue — a per-directory page + is not a new runner, it is the existing one with a smaller list. +- **`todo/`** — the open issues filed against this directory, which are already + markdown next to the code and are the best available description of what is + unfinished in it. + +Generation belongs in `fjs/website/module.f.mjs` as part of the same +`NodeProgram` that owns the rest of the build — the walk that discovers proof +sources today already visits every directory this needs, so this is a second +consumer of one traversal rather than a second traversal. See +[share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) +for the preparation-program boundary this must respect: no npm script running an +impure helper as a second entry point, and any new filesystem capability +expressed as a Node effect with both interpretations proven. + +### Open questions + +- **Does a page run its proofs on load, or on a `Run` click?** Per + [browser-test-controls](../../emergent_testing/todo/browser-test-controls.md) + a suite starts on an explicit action, and a directory page should not be an + exception just because it is small. +- **What does a directory with no `proof` export show?** An empty list is a + worse answer than saying that the modules here are proven from elsewhere, and + naming where. +- **How much of the source is rendered?** Linking to GitHub is free and + immediate; rendering source with highlighting is + [generate-website](generate-website.md)'s item and a larger change. A first + iteration can link out and still be useful. +- **Where does the output tree live**, relative to the isolated browser-test + application root that + [browser-testing](../../emergent_testing/todo/browser-testing.md) describes? + A directory page linking to modules is a page that serves source, which that + issue's application root deliberately does not do. These may be two output + trees rather than one. + +### Constraints + +- The catalog is generated, never hand-maintained: a directory that gains a + module gains it on the page with no edit. +- A page must name a proof exactly as `fjs t` and the browser suite name it. + Three spellings of one test is the problem this repository has been removing. +- Do not build a second test runner. A directory page is the browser + application with a narrower manifest. +- No repository-wide index that has to be regenerated whenever any directory + changes; each page describes its own directory and links to its neighbours. + +### Tasks + +- [ ] Generate an `index.html` per module directory, from the traversal the + website program already performs. +- [ ] List files, subdirectories, `todo/` issues, and a breadcrumb. +- [ ] Run the directory's own proofs on the page, through the existing browser + runner with a narrowed manifest. +- [ ] Decide the source-view question, and link out until it is answered. + +### Related + +- [Generate website](generate-website.md) — README conversion, source + highlighting and `main.css`, all of which this page consumes. +- [Share the browser and console proof runners](../../emergent_testing/todo/share-browser-console-runner.md) + — the preparation-program boundary and the shared test name. +- [Browser testing](../../emergent_testing/todo/browser-testing.md) — the + application root and what it may serve. +- [Explicit browser test controls](../../emergent_testing/todo/browser-test-controls.md) + — a page does not auto-start a run. diff --git a/fjs/website/todo/generate-website.md b/fjs/website/todo/generate-website.md index 959fd8231..62e776d00 100644 --- a/fjs/website/todo/generate-website.md +++ b/fjs/website/todo/generate-website.md @@ -9,6 +9,9 @@ - [ ] Source code highlighting - [ ] One `main.css` - [ ] Convention for `page.f.mjs` — generates a demo webpage for the module in the same directory +- [ ] An `index.html` per module directory, cataloguing its files, + subdirectories, `todo/` issues and local proofs — see + [directory-index-pages](directory-index-pages.md) - [x] Browser test runner and proof-result UI - [ ] Move browser-manifest preparation into the website `NodeProgram` through Node effects, as designed in From dc1b5f3ad2af7d829cebd951da86da4d5fb90270 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:03:22 -0700 Subject: [PATCH 035/370] todo: separate private types into private.ts --- fjs/todo/separate-private-types.md | 93 ++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 fjs/todo/separate-private-types.md diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md new file mode 100644 index 000000000..03d346891 --- /dev/null +++ b/fjs/todo/separate-private-types.md @@ -0,0 +1,93 @@ +## Separate private types into `private.ts` + +**Priority:** P2 +**Status:** open + +### Problem + +FunctionalScript directories currently mix private types with implementation and +public type declarations: + +```text +module.f.mjs # implementation + private JSDoc types +proof.f.mjs # proofs + private JSDoc types +types.ts # public + private TypeScript types +``` + +Private types already use a leading `_` by convention, but their location still +creates declaration and package noise. JSDoc private typedefs in `module.f.mjs` +can be emitted into `module.f.d.mts`, while private declarations in `types.ts` +are emitted into the shipped `types.d.ts`. + +Moving private types to a separate TypeScript file should make the source +boundary explicit and allow package declaration generation to omit private type +artifacts entirely. The leading `_` convention should remain: file placement and +name visibility are complementary signals. + +### Proposal + +Use this directory convention where private named types are needed: + +```text +module.f.mjs # implementation +proof.f.mjs # proofs +types.ts # public types +private.ts # private types +``` + +`private.ts` contains implementation-only TypeScript types used by either the +module or its proofs. Every private type continues to start with `_`. + +Dependency direction: + +```text + types.ts + ^ ^ + | | +private.ts <- module.f.mjs / proof.f.mjs +``` + +- `module.f.mjs` and `proof.f.mjs` may use both `types.ts` and `private.ts` + through JSDoc `@import`. +- `private.ts` may import public types from `types.ts`. +- `types.ts` must not depend on `private.ts`. +- A public exported API must not require a `private.ts` type by name. + +`private.ts` is source-only. It must be type-checked, but package declaration +emission must not generate or ship `private.d.ts`, and the package must not ship +`private.ts` itself. + +Generated declarations such as `module.f.d.mts` may be produced from source that +uses `private.ts`, but no shipped declaration may reference `private.ts` or a +`private.d.ts` artifact. If an exported declaration needs a private type, either +that type is actually public and belongs in `types.ts`, or the public declaration +must be expressible without exposing the private type name. + +This should be enforced by the build/package checks rather than relying only on +review convention. + +### Tasks + +- [ ] Document `private.ts` beside the existing `types.ts`, `module.*`, and + `proof.*` file conventions. +- [ ] Keep the leading `_` convention for every type declared in `private.ts`. +- [ ] Move private named types out of `types.ts`, `module.f.mjs`, and + `proof.f.mjs` into each directory's `private.ts` where applicable. +- [ ] Keep `private.ts` in normal TypeScript type-checking without generating a + runtime JavaScript file for it. +- [ ] Exclude `private.ts` from declaration/package output: do not generate or + ship `private.d.ts` and do not ship `private.ts`. +- [ ] Reject shipped generated declarations that reference `private.ts` or + `private.d.ts`. +- [ ] Add a fixture where `module.f.mjs` and `proof.f.mjs` use `_`-prefixed types + from `private.ts` while the generated public declarations and packed + package contain no private type file. +- [ ] Verify a clean TypeScript consumer can use the packed public API without + any private artifact present. + +### Related + +- [`detect-unexported-types-referenced-by-exported-types.md`](./detect-unexported-types-referenced-by-exported-types.md) — detect private type names that leak through exported types. +- [`document-file-type-naming-conventions.md`](./document-file-type-naming-conventions.md) — document the repository's source-file roles. +- [`../../todo/migrate-typescript-to-mjs.md`](../../todo/migrate-typescript-to-mjs.md) — current JavaScript/JSDoc implementation migration and `_` private-type convention. +- [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) — declaration emission and clean packed-package validation. From 202f749b1ee782141fd444f3b2b9f28cebbd3fb5 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:05:44 -0700 Subject: [PATCH 036/370] todo: prohibit typedefs in implementation files --- fjs/todo/separate-private-types.md | 54 ++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 03d346891..eb2f4a72a 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -15,26 +15,35 @@ types.ts # public + private TypeScript types ``` Private types already use a leading `_` by convention, but their location still -creates declaration and package noise. JSDoc private typedefs in `module.f.mjs` -can be emitted into `module.f.d.mts`, while private declarations in `types.ts` -are emitted into the shipped `types.d.ts`. +creates declaration and package noise. In particular, JSDoc `@typedef`s in +`module.f.mjs` and `proof.f.mjs` escape into generated `.d.mts` files: TypeScript +emits them as exported type aliases even when they were intended to be private. +Private declarations in `types.ts` likewise appear in the shipped `types.d.ts`. -Moving private types to a separate TypeScript file should make the source -boundary explicit and allow package declaration generation to omit private type -artifacts entirely. The leading `_` convention should remain: file placement and -name visibility are complementary signals. +Moving all named types out of implementation/proof files and splitting public +from private TypeScript types removes this leakage structurally instead of +trying to strip it after declaration generation. + +The leading `_` convention should remain: file placement and name visibility are +complementary signals. ### Proposal -Use this directory convention where private named types are needed: +Use this directory convention where named types are needed: ```text -module.f.mjs # implementation -proof.f.mjs # proofs -types.ts # public types -private.ts # private types +module.f.mjs # implementation; no @typedef +proof.f.mjs # proofs; no @typedef +types.ts # public named types +private.ts # private named types ``` +`module.f.mjs` and `proof.f.mjs` may use JSDoc annotations and `@import`, but +must not declare named types with `@typedef`. Named types have exactly two homes: + +- `types.ts` for public types; +- `private.ts` for implementation-only types. + `private.ts` contains implementation-only TypeScript types used by either the module or its proofs. Every private type continues to start with `_`. @@ -53,6 +62,10 @@ private.ts <- module.f.mjs / proof.f.mjs - `types.ts` must not depend on `private.ts`. - A public exported API must not require a `private.ts` type by name. +This leaves generated declarations free to describe the public API, including +structural types inferred from exported values/functions, without also exporting +implementation-local typedef names simply because they were declared in JSDoc. + `private.ts` is source-only. It must be type-checked, but package declaration emission must not generate or ship `private.d.ts`, and the package must not ship `private.ts` itself. @@ -70,7 +83,9 @@ review convention. - [ ] Document `private.ts` beside the existing `types.ts`, `module.*`, and `proof.*` file conventions. +- [ ] Prohibit JSDoc `@typedef` declarations in `module.f.mjs` and `proof.f.mjs`. - [ ] Keep the leading `_` convention for every type declared in `private.ts`. +- [ ] Move public named types from implementation/proof JSDoc into `types.ts`. - [ ] Move private named types out of `types.ts`, `module.f.mjs`, and `proof.f.mjs` into each directory's `private.ts` where applicable. - [ ] Keep `private.ts` in normal TypeScript type-checking without generating a @@ -80,11 +95,22 @@ review convention. - [ ] Reject shipped generated declarations that reference `private.ts` or `private.d.ts`. - [ ] Add a fixture where `module.f.mjs` and `proof.f.mjs` use `_`-prefixed types - from `private.ts` while the generated public declarations and packed - package contain no private type file. + from `private.ts` without declaring any `@typedef`; verify their generated + declarations contain no implementation-local typedef exports and the packed + package contains no private type file. - [ ] Verify a clean TypeScript consumer can use the packed public API without any private artifact present. +### Acceptance criteria + +- `module.f.mjs` and `proof.f.mjs` contain no JSDoc `@typedef` declarations. +- All public named types live in `types.ts`. +- All private named types live in `private.ts` and keep their leading `_`. +- Generated public declarations do not expose private named typedefs merely as a + consequence of declaration emission. +- Neither `private.ts` nor `private.d.ts` is shipped. +- No shipped `.d.ts` / `.d.mts` file references `private.ts` or `private.d.ts`. + ### Related - [`detect-unexported-types-referenced-by-exported-types.md`](./detect-unexported-types-referenced-by-exported-types.md) — detect private type names that leak through exported types. From 1ea3261ba5cbc583e15bbe1493c1054e0fd53c1c Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:09:01 -0700 Subject: [PATCH 037/370] ok --- fjs/djs/serializer/module.f.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fjs/djs/serializer/module.f.mjs b/fjs/djs/serializer/module.f.mjs index 0e8e0bc71..32c39a8c6 100644 --- a/fjs/djs/serializer/module.f.mjs +++ b/fjs/djs/serializer/module.f.mjs @@ -15,12 +15,13 @@ import { fold } from '../../types/list/module.f.mjs' import { concat } from '../../types/string/module.f.mjs' import { flat, flatMap, map, concat as listConcat } from '../../types/list/module.f.mjs' -const { entries } = Object import { compose, fn } from '../../types/function/module.f.mjs' import { serialize as bigintSerialize } from '../../types/bigint/module.f.mjs' import { objectWrap, arrayWrap, colon, stringSerialize, numberSerialize, nullSerialize, boolSerialize } from '../../media/json/serializer/module.f.mjs' import { assertNotNullish } from '../../asserts/module.f.mjs' +const { entries } = Object + export const undefinedSerialize = ['undefined'] /** @typedef {readonly [number, number]} _RefCounter */ From dd3866aeea7b43718ba00905df72d1349547b437 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 15:12:49 +0000 Subject: [PATCH 038/370] changelog: mark the `name` field as a breaking change `renderBrowserReport` is exported and now reads `result.name`, so a report assembled by hand against the previous `{ module, path, ... }` result shape renders `undefined` where its identity should be. The field is required rather than optional on purpose -- a fallback to the old `${module} ${path}` spelling would keep two spellings of a test name alive, which is what this change exists to remove -- so the honest description is a breaking one. Reports produced by `runBrowserProofs`, `startBrowserTests` and `startBrowserTestSources` all carry the field, so no in-repository caller is affected. --- changelog/unreleased/1738.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/changelog/unreleased/1738.md b/changelog/unreleased/1738.md index 9f39ab1eb..a82cc189d 100644 --- a/changelog/unreleased/1738.md +++ b/changelog/unreleased/1738.md @@ -1,3 +1,8 @@ -- `emergent_testing`: the browser report's results carry a `name` — the test - identity `fjs t` prints, built by the same `fmtImport` function — and the page - renders it, so both runners spell a test identically +- **BREAKING CHANGES:** `emergent_testing`: a browser test result gains a + required `name` — the test identity `fjs t` prints, built by the same + `fmtImport` function — and the page renders it, so both runners spell a test + identically. `renderBrowserReport` reads `name`, so a report built by hand + against the previous `{ module, path, ... }` shape renders `undefined` in + place of every identity; reports produced by `runBrowserProofs`, + `startBrowserTests` and `startBrowserTestSources` carry the field and are + unaffected. `module` and `path` are unchanged From fbe4f4811b39e8f07b479675cc1b7710d8ad503f Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:15:12 -0700 Subject: [PATCH 039/370] todo: define private declaration cleanup --- fjs/todo/separate-private-types.md | 52 +++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index eb2f4a72a..dbf993379 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -21,8 +21,10 @@ emits them as exported type aliases even when they were intended to be private. Private declarations in `types.ts` likewise appear in the shipped `types.d.ts`. Moving all named types out of implementation/proof files and splitting public -from private TypeScript types removes this leakage structurally instead of -trying to strip it after declaration generation. +from private TypeScript types removes the JSDoc typedef leakage structurally. +TypeScript will still emit a declaration for an imported `private.ts`, because +that source file is part of the declaration program; that generated private +declaration must be removed before packaging. The leading `_` convention should remain: file placement and name visibility are complementary signals. @@ -66,18 +68,33 @@ This leaves generated declarations free to describe the public API, including structural types inferred from exported values/functions, without also exporting implementation-local typedef names simply because they were declared in JSDoc. -`private.ts` is source-only. It must be type-checked, but package declaration -emission must not generate or ship `private.d.ts`, and the package must not ship -`private.ts` itself. +#### Declaration emission and packaging + +`private.ts` is source-only and remains in the normal TypeScript program so its +types and all `@import` users are checked. Consequently, the existing +`tsc --emitDeclarationOnly` pass will also generate `private.d.ts`; `exclude` +cannot suppress that output once another program input imports `private.ts`. + +Do not require TypeScript to avoid generating that intermediate file. Instead, +make private declaration cleanup an explicit packaging step: + +1. run normal declaration emission and the existing declaration round-trip + type-check; +2. delete every generated `private.d.ts` before `npm pack` selects package + contents; +3. verify that no declaration which remains in the package references + `private.ts` or `private.d.ts`. + +The cleanup must operate on generated artifacts only; authored `private.ts` +remains available for source-tree type-checking. Neither `private.ts` nor the +intermediate generated `private.d.ts` is shipped. Generated declarations such as `module.f.d.mts` may be produced from source that uses `private.ts`, but no shipped declaration may reference `private.ts` or a `private.d.ts` artifact. If an exported declaration needs a private type, either that type is actually public and belongs in `types.ts`, or the public declaration -must be expressible without exposing the private type name. - -This should be enforced by the build/package checks rather than relying only on -review convention. +must be expressible without exposing the private type name. The package check +must fail rather than retaining `private.d.ts` to make such a leak resolve. ### Tasks @@ -90,14 +107,17 @@ review convention. `proof.f.mjs` into each directory's `private.ts` where applicable. - [ ] Keep `private.ts` in normal TypeScript type-checking without generating a runtime JavaScript file for it. -- [ ] Exclude `private.ts` from declaration/package output: do not generate or - ship `private.d.ts` and do not ship `private.ts`. +- [ ] Add a post-declaration-emit packaging step that deletes generated + `private.d.ts` files before `npm pack`. +- [ ] Do not ship authored `private.ts`. - [ ] Reject shipped generated declarations that reference `private.ts` or - `private.d.ts`. + `private.d.ts`; do not preserve `private.d.ts` merely to satisfy such a + reference. - [ ] Add a fixture where `module.f.mjs` and `proof.f.mjs` use `_`-prefixed types - from `private.ts` without declaring any `@typedef`; verify their generated - declarations contain no implementation-local typedef exports and the packed - package contains no private type file. + from `private.ts` without declaring any `@typedef`; verify declaration emit + creates the intermediate `private.d.ts`, cleanup removes it, generated + public declarations contain no implementation-local typedef exports, and + the packed package contains no private type file. - [ ] Verify a clean TypeScript consumer can use the packed public API without any private artifact present. @@ -108,6 +128,8 @@ review convention. - All private named types live in `private.ts` and keep their leading `_`. - Generated public declarations do not expose private named typedefs merely as a consequence of declaration emission. +- Declaration emission may create `private.d.ts`, but the packaging cleanup + removes it before package contents are selected. - Neither `private.ts` nor `private.d.ts` is shipped. - No shipped `.d.ts` / `.d.mts` file references `private.ts` or `private.d.ts`. From f1154ec6a208116e3f9128dbca6846cd64e44e70 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:20:07 -0700 Subject: [PATCH 040/370] todo: validate private types in packed artifact --- fjs/todo/separate-private-types.md | 47 ++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index dbf993379..634500fd7 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -82,19 +82,31 @@ make private declaration cleanup an explicit packaging step: type-check; 2. delete every generated `private.d.ts` before `npm pack` selects package contents; -3. verify that no declaration which remains in the package references - `private.ts` or `private.d.ts`. +3. run `npm pack`; +4. validate the actual packed artifact: + - it contains neither authored `private.ts` nor generated `private.d.ts`; + - every shipped `.d.ts` / `.d.mts` is scanned and must not contain a module + reference to the directory's `private` type module; +5. install the tarball in the existing clean TypeScript consumer and type-check + it as an independent semantic validation. + +The declaration scan should reject the private module rather than only one +particular emitted spelling. For example, `./private.ts`, `./private.d.ts`, or a +future equivalent spelling must all be treated as the same forbidden public +dependency. This is a structural package check over the packed file set and the +contents of all packed declarations, not a check limited to whichever public +entry points the clean consumer happens to import. The cleanup must operate on generated artifacts only; authored `private.ts` remains available for source-tree type-checking. Neither `private.ts` nor the intermediate generated `private.d.ts` is shipped. Generated declarations such as `module.f.d.mts` may be produced from source that -uses `private.ts`, but no shipped declaration may reference `private.ts` or a -`private.d.ts` artifact. If an exported declaration needs a private type, either -that type is actually public and belongs in `types.ts`, or the public declaration -must be expressible without exposing the private type name. The package check -must fail rather than retaining `private.d.ts` to make such a leak resolve. +uses `private.ts`, but no shipped declaration may depend on the private type +module. If an exported declaration needs a private type, either that type is +actually public and belongs in `types.ts`, or the public declaration must be +expressible without exposing the private type name. The package check must fail +rather than retaining `private.d.ts` to make such a leak resolve. ### Tasks @@ -109,17 +121,17 @@ must fail rather than retaining `private.d.ts` to make such a leak resolve. runtime JavaScript file for it. - [ ] Add a post-declaration-emit packaging step that deletes generated `private.d.ts` files before `npm pack`. -- [ ] Do not ship authored `private.ts`. -- [ ] Reject shipped generated declarations that reference `private.ts` or - `private.d.ts`; do not preserve `private.d.ts` merely to satisfy such a - reference. +- [ ] Inspect the `npm pack` artifact and reject any packed `private.ts` or + `private.d.ts` file. +- [ ] Scan every packed `.d.ts` / `.d.mts` and reject any module reference to a + directory's private type module, independent of the exact emitted suffix. - [ ] Add a fixture where `module.f.mjs` and `proof.f.mjs` use `_`-prefixed types from `private.ts` without declaring any `@typedef`; verify declaration emit creates the intermediate `private.d.ts`, cleanup removes it, generated public declarations contain no implementation-local typedef exports, and - the packed package contains no private type file. -- [ ] Verify a clean TypeScript consumer can use the packed public API without - any private artifact present. + packed-artifact validation finds no private type file or declaration edge. +- [ ] Verify a clean TypeScript consumer can install the packed tarball and use + the public API without any private artifact present. ### Acceptance criteria @@ -130,8 +142,11 @@ must fail rather than retaining `private.d.ts` to make such a leak resolve. consequence of declaration emission. - Declaration emission may create `private.d.ts`, but the packaging cleanup removes it before package contents are selected. -- Neither `private.ts` nor `private.d.ts` is shipped. -- No shipped `.d.ts` / `.d.mts` file references `private.ts` or `private.d.ts`. +- The packed tarball contains neither `private.ts` nor `private.d.ts`. +- No packed `.d.ts` / `.d.mts` file depends on a directory's private type module, + regardless of the exact emitted module-specifier suffix. +- A clean TypeScript consumer type-checks successfully against the packed + tarball after all private artifacts have been removed. ### Related From beef545757f5948165643d56c78c026ad00c7844 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:29:18 -0700 Subject: [PATCH 041/370] todo: add rtti.f.mjs type definition convention --- fjs/todo/separate-private-types.md | 72 ++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 634500fd7..9da7fa84b 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -31,17 +31,20 @@ complementary signals. ### Proposal -Use this directory convention where named types are needed: +Use this directory convention where named types or RTTI type definitions are +needed: ```text module.f.mjs # implementation; no @typedef proof.f.mjs # proofs; no @typedef -types.ts # public named types -private.ts # private named types +rtti.f.mjs # runtime type definitions used by RTTI +types.ts # public named TypeScript types +private.ts # private named TypeScript types ``` `module.f.mjs` and `proof.f.mjs` may use JSDoc annotations and `@import`, but -must not declare named types with `@typedef`. Named types have exactly two homes: +must not declare named types with `@typedef`. Named TypeScript types have exactly +two homes: - `types.ts` for public types; - `private.ts` for implementation-only types. @@ -49,18 +52,45 @@ must not declare named types with `@typedef`. Named types have exactly two homes `private.ts` contains implementation-only TypeScript types used by either the module or its proofs. Every private type continues to start with `_`. -Dependency direction: +#### RTTI type definitions + +Some TypeScript types are derived from runtime RTTI definitions, for example: + +```ts +import { type } from './rtti.f.mjs' + +export type Value = Ts +``` + +Put runtime values whose primary purpose is to represent types in `rtti.f.mjs`. +This keeps runtime type definitions distinct from normal implementation code and +from their compile-time TypeScript views: ```text - types.ts - ^ ^ - | | -private.ts <- module.f.mjs / proof.f.mjs +rtti.f.mjs # runtime representation of types +types.ts # public compile-time types +private.ts # private compile-time types ``` +Both `types.ts` and `private.ts` may depend on `rtti.f.mjs` when defining types +such as `Ts`. This is an intentional dependency on a runtime value, +not a violation of the public/private type boundary. `rtti.f.mjs` is ordinary +runtime FunctionalScript source and is packaged like other required `.f.mjs` +modules; it is not a private type artifact merely because `private.ts` may use +it. + +Do not move an RTTI value into `types.ts` or `private.ts` merely to avoid this +dependency: the RTTI definition is a runtime value and belongs in `.f.mjs`. +Normal runtime values whose primary purpose is not type representation remain in +`module.f.mjs` rather than being moved mechanically to `rtti.f.mjs`. + +Dependency rules: + - `module.f.mjs` and `proof.f.mjs` may use both `types.ts` and `private.ts` through JSDoc `@import`. - `private.ts` may import public types from `types.ts`. +- `types.ts` and `private.ts` may depend on runtime type definitions from + `rtti.f.mjs` for `Ts` and similar type derivation. - `types.ts` must not depend on `private.ts`. - A public exported API must not require a `private.ts` type by name. @@ -97,6 +127,10 @@ dependency. This is a structural package check over the packed file set and the contents of all packed declarations, not a check limited to whichever public entry points the clean consumer happens to import. +References from shipped declarations to a packaged `rtti.f.mjs` are allowed: +unlike `private.ts`, RTTI is a runtime module intentionally available to the +package and may be required to express public types derived with `Ts`. + The cleanup must operate on generated artifacts only; authored `private.ts` remains available for source-tree type-checking. Neither `private.ts` nor the intermediate generated `private.d.ts` is shipped. @@ -110,13 +144,16 @@ rather than retaining `private.d.ts` to make such a leak resolve. ### Tasks -- [ ] Document `private.ts` beside the existing `types.ts`, `module.*`, and - `proof.*` file conventions. +- [ ] Document `private.ts` and `rtti.f.mjs` beside the existing `types.ts`, + `module.*`, and `proof.*` file conventions. - [ ] Prohibit JSDoc `@typedef` declarations in `module.f.mjs` and `proof.f.mjs`. - [ ] Keep the leading `_` convention for every type declared in `private.ts`. - [ ] Move public named types from implementation/proof JSDoc into `types.ts`. - [ ] Move private named types out of `types.ts`, `module.f.mjs`, and `proof.f.mjs` into each directory's `private.ts` where applicable. +- [ ] Move runtime RTTI definitions whose primary purpose is type representation + into `rtti.f.mjs` when `types.ts` or `private.ts` derives TypeScript types + from them with `Ts` or an equivalent type query. - [ ] Keep `private.ts` in normal TypeScript type-checking without generating a runtime JavaScript file for it. - [ ] Add a post-declaration-emit packaging step that deletes generated @@ -130,14 +167,21 @@ rather than retaining `private.d.ts` to make such a leak resolve. creates the intermediate `private.d.ts`, cleanup removes it, generated public declarations contain no implementation-local typedef exports, and packed-artifact validation finds no private type file or declaration edge. +- [ ] Extend the fixture with `rtti.f.mjs` plus a `Ts`-derived type in + `types.ts` or `private.ts`; verify the source tree and packed consumer both + resolve the RTTI dependency correctly. - [ ] Verify a clean TypeScript consumer can install the packed tarball and use the public API without any private artifact present. ### Acceptance criteria - `module.f.mjs` and `proof.f.mjs` contain no JSDoc `@typedef` declarations. -- All public named types live in `types.ts`. -- All private named types live in `private.ts` and keep their leading `_`. +- All public named TypeScript types live in `types.ts`. +- All private named TypeScript types live in `private.ts` and keep their leading + `_`. +- Runtime values whose primary purpose is RTTI type representation live in + `rtti.f.mjs`; `types.ts` and `private.ts` may depend on them for + `Ts`-style type derivation. - Generated public declarations do not expose private named typedefs merely as a consequence of declaration emission. - Declaration emission may create `private.d.ts`, but the packaging cleanup @@ -145,6 +189,8 @@ rather than retaining `private.d.ts` to make such a leak resolve. - The packed tarball contains neither `private.ts` nor `private.d.ts`. - No packed `.d.ts` / `.d.mts` file depends on a directory's private type module, regardless of the exact emitted module-specifier suffix. +- Required references to packaged `rtti.f.mjs` modules remain valid in the + packed artifact and clean consumer. - A clean TypeScript consumer type-checks successfully against the packed tarball after all private artifacts have been removed. From 9bddb3375499db07a4278ee99dcec5cc4e30586c Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:34:15 -0700 Subject: [PATCH 042/370] todo: rename RTTI companion to meta --- fjs/todo/separate-private-types.md | 79 +++++++++++++++++------------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 9da7fa84b..45fff31de 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -31,13 +31,13 @@ complementary signals. ### Proposal -Use this directory convention where named types or RTTI type definitions are -needed: +Use this directory convention where named types or runtime metadata used for +type derivation are needed: ```text module.f.mjs # implementation; no @typedef proof.f.mjs # proofs; no @typedef -rtti.f.mjs # runtime type definitions used by RTTI +meta.f.mjs # runtime metadata used to define/derive types types.ts # public named TypeScript types private.ts # private named TypeScript types ``` @@ -52,45 +52,56 @@ two homes: `private.ts` contains implementation-only TypeScript types used by either the module or its proofs. Every private type continues to start with `_`. -#### RTTI type definitions +#### Type metadata -Some TypeScript types are derived from runtime RTTI definitions, for example: +Some TypeScript types are derived from runtime values rather than declared +independently. These values include RTTI definitions, for example: ```ts -import { type } from './rtti.f.mjs' +import { type } from './meta.f.mjs' export type Value = Ts ``` -Put runtime values whose primary purpose is to represent types in `rtti.f.mjs`. -This keeps runtime type definitions distinct from normal implementation code and -from their compile-time TypeScript views: +and ordinary constants whose literal value is used by a type query, for example: + +```ts +import { statuses } from './meta.f.mjs' + +export type Status = typeof statuses[number] +``` + +Put runtime values whose primary purpose is to define, describe, or derive +type-level information in `meta.f.mjs`. This keeps type metadata distinct from +normal implementation code and from its compile-time TypeScript views: ```text -rtti.f.mjs # runtime representation of types +meta.f.mjs # runtime metadata used by types types.ts # public compile-time types private.ts # private compile-time types ``` -Both `types.ts` and `private.ts` may depend on `rtti.f.mjs` when defining types -such as `Ts`. This is an intentional dependency on a runtime value, -not a violation of the public/private type boundary. `rtti.f.mjs` is ordinary +Both `types.ts` and `private.ts` may depend on `meta.f.mjs` for +`Ts`, `typeof ...`, indexed access over `as const`-style values, and +similar type derivation. This is an intentional dependency on runtime values, +not a violation of the public/private type boundary. `meta.f.mjs` is ordinary runtime FunctionalScript source and is packaged like other required `.f.mjs` modules; it is not a private type artifact merely because `private.ts` may use it. -Do not move an RTTI value into `types.ts` or `private.ts` merely to avoid this -dependency: the RTTI definition is a runtime value and belongs in `.f.mjs`. -Normal runtime values whose primary purpose is not type representation remain in -`module.f.mjs` rather than being moved mechanically to `rtti.f.mjs`. +Do not move a runtime value into `types.ts` or `private.ts` merely to avoid this +dependency: runtime values belong in `.f.mjs`. Values whose primary purpose is +normal program behavior remain in `module.f.mjs` even if their types are reused +incidentally; `meta.f.mjs` is for values whose primary role is type-level +metadata. Dependency rules: - `module.f.mjs` and `proof.f.mjs` may use both `types.ts` and `private.ts` through JSDoc `@import`. - `private.ts` may import public types from `types.ts`. -- `types.ts` and `private.ts` may depend on runtime type definitions from - `rtti.f.mjs` for `Ts` and similar type derivation. +- `types.ts` and `private.ts` may depend on runtime type metadata from + `meta.f.mjs` for `Ts`, `typeof ...`, and similar derivation. - `types.ts` must not depend on `private.ts`. - A public exported API must not require a `private.ts` type by name. @@ -127,9 +138,9 @@ dependency. This is a structural package check over the packed file set and the contents of all packed declarations, not a check limited to whichever public entry points the clean consumer happens to import. -References from shipped declarations to a packaged `rtti.f.mjs` are allowed: -unlike `private.ts`, RTTI is a runtime module intentionally available to the -package and may be required to express public types derived with `Ts`. +References from shipped declarations to a packaged `meta.f.mjs` are allowed: +unlike `private.ts`, metadata is an intentionally packaged runtime module and +may be required to express public types derived from its exported values. The cleanup must operate on generated artifacts only; authored `private.ts` remains available for source-tree type-checking. Neither `private.ts` nor the @@ -144,16 +155,16 @@ rather than retaining `private.d.ts` to make such a leak resolve. ### Tasks -- [ ] Document `private.ts` and `rtti.f.mjs` beside the existing `types.ts`, +- [ ] Document `private.ts` and `meta.f.mjs` beside the existing `types.ts`, `module.*`, and `proof.*` file conventions. - [ ] Prohibit JSDoc `@typedef` declarations in `module.f.mjs` and `proof.f.mjs`. - [ ] Keep the leading `_` convention for every type declared in `private.ts`. - [ ] Move public named types from implementation/proof JSDoc into `types.ts`. - [ ] Move private named types out of `types.ts`, `module.f.mjs`, and `proof.f.mjs` into each directory's `private.ts` where applicable. -- [ ] Move runtime RTTI definitions whose primary purpose is type representation - into `rtti.f.mjs` when `types.ts` or `private.ts` derives TypeScript types - from them with `Ts` or an equivalent type query. +- [ ] Move runtime values whose primary purpose is type derivation into + `meta.f.mjs`; include both RTTI definitions and non-RTTI constants used by + `Ts`, `typeof ...`, or equivalent type queries. - [ ] Keep `private.ts` in normal TypeScript type-checking without generating a runtime JavaScript file for it. - [ ] Add a post-declaration-emit packaging step that deletes generated @@ -167,9 +178,10 @@ rather than retaining `private.d.ts` to make such a leak resolve. creates the intermediate `private.d.ts`, cleanup removes it, generated public declarations contain no implementation-local typedef exports, and packed-artifact validation finds no private type file or declaration edge. -- [ ] Extend the fixture with `rtti.f.mjs` plus a `Ts`-derived type in - `types.ts` or `private.ts`; verify the source tree and packed consumer both - resolve the RTTI dependency correctly. +- [ ] Extend the fixture with `meta.f.mjs` containing both an RTTI value and a + non-RTTI literal constant used to derive TypeScript types in `types.ts` or + `private.ts`; verify the source tree and packed consumer resolve both + metadata dependencies correctly. - [ ] Verify a clean TypeScript consumer can install the packed tarball and use the public API without any private artifact present. @@ -179,9 +191,10 @@ rather than retaining `private.d.ts` to make such a leak resolve. - All public named TypeScript types live in `types.ts`. - All private named TypeScript types live in `private.ts` and keep their leading `_`. -- Runtime values whose primary purpose is RTTI type representation live in - `rtti.f.mjs`; `types.ts` and `private.ts` may depend on them for - `Ts`-style type derivation. +- Runtime values whose primary purpose is to define, describe, or derive + type-level information live in `meta.f.mjs`; this includes RTTI definitions + and non-RTTI constants used by `Ts`, `typeof ...`, and similar + type queries. - Generated public declarations do not expose private named typedefs merely as a consequence of declaration emission. - Declaration emission may create `private.d.ts`, but the packaging cleanup @@ -189,7 +202,7 @@ rather than retaining `private.d.ts` to make such a leak resolve. - The packed tarball contains neither `private.ts` nor `private.d.ts`. - No packed `.d.ts` / `.d.mts` file depends on a directory's private type module, regardless of the exact emitted module-specifier suffix. -- Required references to packaged `rtti.f.mjs` modules remain valid in the +- Required references to packaged `meta.f.mjs` modules remain valid in the packed artifact and clean consumer. - A clean TypeScript consumer type-checks successfully against the packed tarball after all private artifacts have been removed. From a14d7b8e9c9c0eed154e23ba0cc848a7d23863f2 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:39:37 -0700 Subject: [PATCH 043/370] todo: allow function-local typedefs --- fjs/todo/separate-private-types.md | 106 +++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 30 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 45fff31de..bba8bbb84 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -15,16 +15,17 @@ types.ts # public + private TypeScript types ``` Private types already use a leading `_` by convention, but their location still -creates declaration and package noise. In particular, JSDoc `@typedef`s in -`module.f.mjs` and `proof.f.mjs` escape into generated `.d.mts` files: TypeScript -emits them as exported type aliases even when they were intended to be private. -Private declarations in `types.ts` likewise appear in the shipped `types.d.ts`. - -Moving all named types out of implementation/proof files and splitting public -from private TypeScript types removes the JSDoc typedef leakage structurally. -TypeScript will still emit a declaration for an imported `private.ts`, because -that source file is part of the declaration program; that generated private -declaration must be removed before packaging. +creates declaration and package noise. In particular, file-scope JSDoc +`@typedef`s in `module.f.mjs` and `proof.f.mjs` escape into generated `.d.mts` +files: TypeScript emits them as exported type aliases even when they were +intended to be private. Private declarations in `types.ts` likewise appear in +the shipped `types.d.ts`. + +Moving file-scope named types out of implementation/proof files and splitting +public from private TypeScript types removes the JSDoc typedef leakage +structurally. TypeScript will still emit a declaration for an imported +`private.ts`, because that source file is part of the declaration program; that +generated private declaration must be removed before packaging. The leading `_` convention should remain: file placement and name visibility are complementary signals. @@ -35,22 +36,54 @@ Use this directory convention where named types or runtime metadata used for type derivation are needed: ```text -module.f.mjs # implementation; no @typedef -proof.f.mjs # proofs; no @typedef +module.f.mjs # implementation; no file-scope @typedef +proof.f.mjs # proofs; no file-scope @typedef meta.f.mjs # runtime metadata used to define/derive types types.ts # public named TypeScript types private.ts # private named TypeScript types ``` `module.f.mjs` and `proof.f.mjs` may use JSDoc annotations and `@import`, but -must not declare named types with `@typedef`. Named TypeScript types have exactly -two homes: +must not declare file-scope named types with `@typedef`. File-scope named +TypeScript types have exactly two homes: - `types.ts` for public types; - `private.ts` for implementation-only types. `private.ts` contains implementation-only TypeScript types used by either the -module or its proofs. Every private type continues to start with `_`. +module or its proofs. Every private file-scope type continues to start with `_`. + +#### Lexically scoped typedefs + +Function-local JSDoc `@typedef` declarations are allowed everywhere. They are +useful for compile-time proofs that depend on values available only in lexical +scope and therefore cannot be moved to `private.ts` without exposing or +restructuring implementation locals. + +For example: + +```js +const proof = () => { + const value = f(42) + /** @typedef {Assert>} _Value */ +} +``` + +Such a typedef is a local type assertion, not a file-level API declaration. Keep +it inside the narrowest function scope that provides the values it needs; do not +move it to `private.ts` merely to satisfy the file convention. Private local +typedef names should keep the leading `_` convention. + +The distinction is therefore scope-based: + +```text +file-scope public named type -> types.ts +file-scope private named type -> private.ts +function-local typedef -> allowed in place +``` + +Declaration validation must verify that function-local typedefs remain lexical +and do not appear as exported aliases in generated `.d.ts` / `.d.mts` files. #### Type metadata @@ -107,7 +140,8 @@ Dependency rules: This leaves generated declarations free to describe the public API, including structural types inferred from exported values/functions, without also exporting -implementation-local typedef names simply because they were declared in JSDoc. +implementation-local file-scope typedef names simply because they were declared +in JSDoc. #### Declaration emission and packaging @@ -157,11 +191,16 @@ rather than retaining `private.d.ts` to make such a leak resolve. - [ ] Document `private.ts` and `meta.f.mjs` beside the existing `types.ts`, `module.*`, and `proof.*` file conventions. -- [ ] Prohibit JSDoc `@typedef` declarations in `module.f.mjs` and `proof.f.mjs`. -- [ ] Keep the leading `_` convention for every type declared in `private.ts`. -- [ ] Move public named types from implementation/proof JSDoc into `types.ts`. -- [ ] Move private named types out of `types.ts`, `module.f.mjs`, and +- [ ] Prohibit file-scope JSDoc `@typedef` declarations in `module.f.mjs` and + `proof.f.mjs`; allow function-local `@typedef` declarations everywhere. +- [ ] Keep the leading `_` convention for private types, including function-local + private typedefs. +- [ ] Move public file-scope named types from implementation/proof JSDoc into + `types.ts`. +- [ ] Move private file-scope named types out of `types.ts`, `module.f.mjs`, and `proof.f.mjs` into each directory's `private.ts` where applicable. +- [ ] Keep lexical type-proof typedefs inside the functions whose local values + they inspect; do not force them into `private.ts`. - [ ] Move runtime values whose primary purpose is type derivation into `meta.f.mjs`; include both RTTI definitions and non-RTTI constants used by `Ts`, `typeof ...`, or equivalent type queries. @@ -174,10 +213,13 @@ rather than retaining `private.d.ts` to make such a leak resolve. - [ ] Scan every packed `.d.ts` / `.d.mts` and reject any module reference to a directory's private type module, independent of the exact emitted suffix. - [ ] Add a fixture where `module.f.mjs` and `proof.f.mjs` use `_`-prefixed types - from `private.ts` without declaring any `@typedef`; verify declaration emit - creates the intermediate `private.d.ts`, cleanup removes it, generated - public declarations contain no implementation-local typedef exports, and - packed-artifact validation finds no private type file or declaration edge. + from `private.ts` without declaring any file-scope `@typedef`; also include + a function-local typedef that depends on a lexical value and verify it does + not escape into generated declarations. Verify declaration emit creates the + intermediate `private.d.ts`, cleanup removes it, generated public + declarations contain no implementation-local file-scope typedef exports, + and packed-artifact validation finds no private type file or declaration + edge. - [ ] Extend the fixture with `meta.f.mjs` containing both an RTTI value and a non-RTTI literal constant used to derive TypeScript types in `types.ts` or `private.ts`; verify the source tree and packed consumer resolve both @@ -187,16 +229,20 @@ rather than retaining `private.d.ts` to make such a leak resolve. ### Acceptance criteria -- `module.f.mjs` and `proof.f.mjs` contain no JSDoc `@typedef` declarations. -- All public named TypeScript types live in `types.ts`. -- All private named TypeScript types live in `private.ts` and keep their leading - `_`. +- `module.f.mjs` and `proof.f.mjs` contain no file-scope JSDoc `@typedef` + declarations. +- Function-local JSDoc `@typedef` declarations are allowed in any source file and + may depend on lexical values; they do not escape as exported declaration + aliases. +- All public file-scope named TypeScript types live in `types.ts`. +- All private file-scope named TypeScript types live in `private.ts` and keep + their leading `_`; private function-local typedefs also keep `_`. - Runtime values whose primary purpose is to define, describe, or derive type-level information live in `meta.f.mjs`; this includes RTTI definitions and non-RTTI constants used by `Ts`, `typeof ...`, and similar type queries. -- Generated public declarations do not expose private named typedefs merely as a - consequence of declaration emission. +- Generated public declarations do not expose private file-scope named typedefs + merely as a consequence of declaration emission. - Declaration emission may create `private.d.ts`, but the packaging cleanup removes it before package contents are selected. - The packed tarball contains neither `private.ts` nor `private.d.ts`. From 18ab3d91a2994d80f441f259410fb9b0e150fea1 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:42:01 -0700 Subject: [PATCH 044/370] todo: put private declaration cleanup in prepack --- fjs/todo/separate-private-types.md | 57 +++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index bba8bbb84..33c1fd3a8 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -151,20 +151,41 @@ types and all `@import` users are checked. Consequently, the existing cannot suppress that output once another program input imports `private.ts`. Do not require TypeScript to avoid generating that intermediate file. Instead, -make private declaration cleanup an explicit packaging step: - -1. run normal declaration emission and the existing declaration round-trip - type-check; -2. delete every generated `private.d.ts` before `npm pack` selects package - contents; -3. run `npm pack`; -4. validate the actual packed artifact: +make private declaration cleanup the **final step of `prepack`**. `npm pack` +runs `prepack` itself, so an external emit/check/cleanup sequence followed by +`npm pack` would be wrong: `npm pack` would rerun declaration emission and +recreate the deleted `private.d.ts` before selecting package contents. + +The packaging lifecycle should therefore be: + +1. `prepack` runs normal declaration emission; +2. `prepack` runs the existing declaration round-trip type-check; +3. as the final `prepack` command, delete every generated `private.d.ts`; +4. `npm pack` selects package contents after `prepack` completes; +5. validate the actual packed artifact: - it contains neither authored `private.ts` nor generated `private.d.ts`; - every shipped `.d.ts` / `.d.mts` is scanned and must not contain a module reference to the directory's `private` type module; -5. install the tarball in the existing clean TypeScript consumer and type-check +6. install the tarball in the existing clean TypeScript consumer and type-check it as an independent semantic validation. +Conceptually, the current `prepack`: + +```text +tsc --noEmit false --emitDeclarationOnly && tsc +``` + +becomes: + +```text +tsc --noEmit false --emitDeclarationOnly && tsc && +``` + +The cleanup command should be implemented with repository-portable tooling; the +exact command is part of implementation, not this source-layout convention. +Tests should invoke `npm pack` normally so they exercise the real lifecycle, +rather than reproducing `prepack` steps externally. + The declaration scan should reject the private module rather than only one particular emitted spelling. For example, `./private.ts`, `./private.d.ts`, or a future equivalent spelling must all be treated as the same forbidden public @@ -206,8 +227,10 @@ rather than retaining `private.d.ts` to make such a leak resolve. `Ts`, `typeof ...`, or equivalent type queries. - [ ] Keep `private.ts` in normal TypeScript type-checking without generating a runtime JavaScript file for it. -- [ ] Add a post-declaration-emit packaging step that deletes generated - `private.d.ts` files before `npm pack`. +- [ ] Make deletion of generated `private.d.ts` files the final `prepack` step, + after declaration emission and the declaration round-trip check. +- [ ] Exercise cleanup through a normal `npm pack`; do not run cleanup externally + before `npm pack`, because `npm pack` reruns `prepack`. - [ ] Inspect the `npm pack` artifact and reject any packed `private.ts` or `private.d.ts` file. - [ ] Scan every packed `.d.ts` / `.d.mts` and reject any module reference to a @@ -216,10 +239,10 @@ rather than retaining `private.d.ts` to make such a leak resolve. from `private.ts` without declaring any file-scope `@typedef`; also include a function-local typedef that depends on a lexical value and verify it does not escape into generated declarations. Verify declaration emit creates the - intermediate `private.d.ts`, cleanup removes it, generated public - declarations contain no implementation-local file-scope typedef exports, - and packed-artifact validation finds no private type file or declaration - edge. + intermediate `private.d.ts`, final-`prepack` cleanup removes it, generated + public declarations contain no implementation-local file-scope typedef + exports, and packed-artifact validation finds no private type file or + declaration edge. - [ ] Extend the fixture with `meta.f.mjs` containing both an RTTI value and a non-RTTI literal constant used to derive TypeScript types in `types.ts` or `private.ts`; verify the source tree and packed consumer resolve both @@ -243,8 +266,8 @@ rather than retaining `private.d.ts` to make such a leak resolve. type queries. - Generated public declarations do not expose private file-scope named typedefs merely as a consequence of declaration emission. -- Declaration emission may create `private.d.ts`, but the packaging cleanup - removes it before package contents are selected. +- Declaration emission may create `private.d.ts`; the final `prepack` step + removes it, and `npm pack` selects contents only after that cleanup completes. - The packed tarball contains neither `private.ts` nor `private.d.ts`. - No packed `.d.ts` / `.d.mts` file depends on a directory's private type module, regardless of the exact emitted module-specifier suffix. From 0b1e6f9957dfbbf9c8dfaa5a1acc8c73cf7c2bb9 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:46:12 -0700 Subject: [PATCH 045/370] todo: cover meta FunctionalScript files --- fjs/todo/separate-private-types.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 33c1fd3a8..d2056951e 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -128,6 +128,13 @@ normal program behavior remain in `module.f.mjs` even if their types are reused incidentally; `meta.f.mjs` is for values whose primary role is type-level metadata. +`meta.f.mjs` is executable FunctionalScript source, not a declaration-only +companion. Coverage must therefore treat it like `module.f.mjs`: the Node and +Deno coverage filters must include `meta.f.mjs`, and proofs must execute the +metadata code sufficiently for the repository's existing coverage thresholds to +apply. Moving executable values from `module.f.mjs` to `meta.f.mjs` must not make +them disappear from coverage. + Dependency rules: - `module.f.mjs` and `proof.f.mjs` may use both `types.ts` and `private.ts` @@ -225,6 +232,10 @@ rather than retaining `private.d.ts` to make such a leak resolve. - [ ] Move runtime values whose primary purpose is type derivation into `meta.f.mjs`; include both RTTI definitions and non-RTTI constants used by `Ts`, `typeof ...`, or equivalent type queries. +- [ ] Update Node coverage selection to include both `module.f.mjs` and + `meta.f.mjs` under the existing 100% thresholds. +- [ ] Update Deno `cov` and `cov-html` include filters to include both + `module.f.mjs` and `meta.f.mjs`. - [ ] Keep `private.ts` in normal TypeScript type-checking without generating a runtime JavaScript file for it. - [ ] Make deletion of generated `private.d.ts` files the final `prepack` step, @@ -246,7 +257,8 @@ rather than retaining `private.d.ts` to make such a leak resolve. - [ ] Extend the fixture with `meta.f.mjs` containing both an RTTI value and a non-RTTI literal constant used to derive TypeScript types in `types.ts` or `private.ts`; verify the source tree and packed consumer resolve both - metadata dependencies correctly. + metadata dependencies correctly, and that executable `meta.f.mjs` code is + included in both Node and Deno coverage. - [ ] Verify a clean TypeScript consumer can install the packed tarball and use the public API without any private artifact present. @@ -264,6 +276,8 @@ rather than retaining `private.d.ts` to make such a leak resolve. type-level information live in `meta.f.mjs`; this includes RTTI definitions and non-RTTI constants used by `Ts`, `typeof ...`, and similar type queries. +- Node and Deno coverage include executable `meta.f.mjs` files under the same + coverage expectations as `module.f.mjs`. - Generated public declarations do not expose private file-scope named typedefs merely as a consequence of declaration emission. - Declaration emission may create `private.d.ts`; the final `prepack` step From 3ddac53c1e40592681d8dc04a0a5217dc7955458 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:48:28 -0700 Subject: [PATCH 046/370] todo: require import type in TypeScript type files --- fjs/todo/separate-private-types.md | 46 ++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index d2056951e..55be51314 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -91,7 +91,7 @@ Some TypeScript types are derived from runtime values rather than declared independently. These values include RTTI definitions, for example: ```ts -import { type } from './meta.f.mjs' +import type { type } from './meta.f.mjs' export type Value = Ts ``` @@ -99,7 +99,7 @@ export type Value = Ts and ordinary constants whose literal value is used by a type query, for example: ```ts -import { statuses } from './meta.f.mjs' +import type { statuses } from './meta.f.mjs' export type Status = typeof statuses[number] ``` @@ -116,11 +116,25 @@ private.ts # private compile-time types Both `types.ts` and `private.ts` may depend on `meta.f.mjs` for `Ts`, `typeof ...`, indexed access over `as const`-style values, and -similar type derivation. This is an intentional dependency on runtime values, -not a violation of the public/private type boundary. `meta.f.mjs` is ordinary -runtime FunctionalScript source and is packaged like other required `.f.mjs` -modules; it is not a private type artifact merely because `private.ts` may use -it. +similar type derivation. These dependencies are still type-only from TypeScript's +point of view: the imported value is mentioned only through a type query, so the +import must be erased. + +All imports in authored TypeScript type files use the named type-only form: + +```ts +import type { PublicType } from './types.ts' +import type { metadataValue } from './meta.f.mjs' +``` + +Do not use a runtime `import { ... }`, `import * as ...`, or side-effect import in +`types.ts` or `private.ts`. This matches the repository rule that authored +TypeScript is type-only source and prevents a type module from acquiring runtime +behavior merely because `typeof` refers to an exported `.f.mjs` value. + +`meta.f.mjs` itself is ordinary runtime FunctionalScript source and is packaged +like other required `.f.mjs` modules; it is not a private type artifact merely +because `private.ts` may use it. Do not move a runtime value into `types.ts` or `private.ts` merely to avoid this dependency: runtime values belong in `.f.mjs`. Values whose primary purpose is @@ -139,9 +153,11 @@ Dependency rules: - `module.f.mjs` and `proof.f.mjs` may use both `types.ts` and `private.ts` through JSDoc `@import`. -- `private.ts` may import public types from `types.ts`. -- `types.ts` and `private.ts` may depend on runtime type metadata from +- `private.ts` may `import type { ... }` public types from `types.ts`. +- `types.ts` and `private.ts` may `import type { ... }` runtime type metadata from `meta.f.mjs` for `Ts`, `typeof ...`, and similar derivation. +- all imports in `types.ts` and `private.ts` are named `import type { ... }` + imports; they must not create runtime dependencies. - `types.ts` must not depend on `private.ts`. - A public exported API must not require a `private.ts` type by name. @@ -232,6 +248,9 @@ rather than retaining `private.d.ts` to make such a leak resolve. - [ ] Move runtime values whose primary purpose is type derivation into `meta.f.mjs`; include both RTTI definitions and non-RTTI constants used by `Ts`, `typeof ...`, or equivalent type queries. +- [ ] Require every import in authored TypeScript type files (`types.ts` and + `private.ts`) to use named `import type { ... }`, including imports from + `meta.f.mjs` used only through `typeof`. - [ ] Update Node coverage selection to include both `module.f.mjs` and `meta.f.mjs` under the existing 100% thresholds. - [ ] Update Deno `cov` and `cov-html` include filters to include both @@ -256,9 +275,9 @@ rather than retaining `private.d.ts` to make such a leak resolve. declaration edge. - [ ] Extend the fixture with `meta.f.mjs` containing both an RTTI value and a non-RTTI literal constant used to derive TypeScript types in `types.ts` or - `private.ts`; verify the source tree and packed consumer resolve both - metadata dependencies correctly, and that executable `meta.f.mjs` code is - included in both Node and Deno coverage. + `private.ts`; import both with `import type { ... }`, verify the source tree + and packed consumer resolve both metadata dependencies correctly, and that + executable `meta.f.mjs` code is included in both Node and Deno coverage. - [ ] Verify a clean TypeScript consumer can install the packed tarball and use the public API without any private artifact present. @@ -272,6 +291,9 @@ rather than retaining `private.d.ts` to make such a leak resolve. - All public file-scope named TypeScript types live in `types.ts`. - All private file-scope named TypeScript types live in `private.ts` and keep their leading `_`; private function-local typedefs also keep `_`. +- Every import in `types.ts` and `private.ts` uses named `import type { ... }`; + these files have no runtime imports, including for `.f.mjs` values referenced + only through `typeof`. - Runtime values whose primary purpose is to define, describe, or derive type-level information live in `meta.f.mjs`; this includes RTTI definitions and non-RTTI constants used by `Ts`, `typeof ...`, and similar From c64c52e19b509f50f082b2545c33a5b4b8984b20 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:51:37 -0700 Subject: [PATCH 047/370] todo: allow private helpers in public type graph --- fjs/todo/separate-private-types.md | 328 +++++++++++++---------------- 1 file changed, 147 insertions(+), 181 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 55be51314..02d1b7fea 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -11,24 +11,31 @@ public type declarations: ```text module.f.mjs # implementation + private JSDoc types proof.f.mjs # proofs + private JSDoc types -types.ts # public + private TypeScript types +types.ts # public type API + private helper types ``` Private types already use a leading `_` by convention, but their location still creates declaration and package noise. In particular, file-scope JSDoc `@typedef`s in `module.f.mjs` and `proof.f.mjs` escape into generated `.d.mts` files: TypeScript emits them as exported type aliases even when they were -intended to be private. Private declarations in `types.ts` likewise appear in -the shipped `types.d.ts`. +intended to be private. -Moving file-scope named types out of implementation/proof files and splitting -public from private TypeScript types removes the JSDoc typedef leakage -structurally. TypeScript will still emit a declaration for an imported -`private.ts`, because that source file is part of the declaration program; that -generated private declaration must be removed before packaging. +There are two different kinds of private file-scope type, and the convention must +not confuse them: -The leading `_` convention should remain: file placement and name visibility are -complementary signals. +1. **public-type helpers** such as `_Tuple` that are required to express an + exported type such as `Tuple`; these must stay with the public declaration + graph in `types.ts`; +2. **implementation-private types** used only by implementation/proofs; these + belong in `private.ts`. + +Moving the second category out of implementation/proof files removes the JSDoc +typedef leakage structurally. TypeScript will still emit a declaration for an +imported `private.ts`, because that source file is part of the declaration +program; that generated private declaration must be removed before packaging. + +The leading `_` convention remains useful in both categories: it means the type +name itself is private even when the helper must live beside public types. ### Proposal @@ -39,56 +46,77 @@ type derivation are needed: module.f.mjs # implementation; no file-scope @typedef proof.f.mjs # proofs; no file-scope @typedef meta.f.mjs # runtime metadata used to define/derive types -types.ts # public named TypeScript types -private.ts # private named TypeScript types +types.ts # public types + `_` helpers required to express them +private.ts # implementation-private `_` types ``` `module.f.mjs` and `proof.f.mjs` may use JSDoc annotations and `@import`, but -must not declare file-scope named types with `@typedef`. File-scope named -TypeScript types have exactly two homes: +must not declare file-scope named types with `@typedef`. + +The placement rule is based on the type dependency graph, not only visibility: + +```text +public type -> types.ts +private `_` helper required by a public type -> types.ts +other file-scope private `_` type -> private.ts +function-local typedef -> allowed in place +``` + +A `_` helper required to define a public type is still private by name and need +not be exported from `types.ts`. Keeping it there allows TypeScript to emit a +self-contained public declaration module. Moving it to `private.ts` would make a +shipped declaration depend on a module that packaging deliberately removes. -- `types.ts` for public types; -- `private.ts` for implementation-only types. +For example: + +```ts +type _Tuple = + N extends R['length'] ? R : _Tuple -`private.ts` contains implementation-only TypeScript types used by either the -module or its proofs. Every private file-scope type continues to start with `_`. +export type Tuple = _Tuple +``` -#### Lexically scoped typedefs +`_Tuple` stays in `types.ts`: it is private, but it is part of the implementation +of the public `Tuple` declaration. By contrast, a `_State` used only to annotate +`module.f.mjs` belongs in `private.ts`. + +`types.ts` must never import `private.ts`. If moving a private alias out of +`types.ts` would create such an edge, that is evidence that the alias is a +public-type helper and should remain in `types.ts`. + +#### Function-local typedefs Function-local JSDoc `@typedef` declarations are allowed everywhere. They are useful for compile-time proofs that depend on values available only in lexical -scope and therefore cannot be moved to `private.ts` without exposing or -restructuring implementation locals. +scope and therefore cannot be moved to `private.ts`. For example: ```js const proof = () => { - const value = f(42) - /** @typedef {Assert>} _Value */ + const orConst = or(42, string) + /** @typedef {Assert>>} _OrConst */ } ``` -Such a typedef is a local type assertion, not a file-level API declaration. Keep -it inside the narrowest function scope that provides the values it needs; do not -move it to `private.ts` merely to satisfy the file convention. Private local -typedef names should keep the leading `_` convention. - -The distinction is therefore scope-based: +A callback-local proof is also valid: -```text -file-scope public named type -> types.ts -file-scope private named type -> private.ts -function-local typedef -> allowed in place +```js +({ kind }) => { + /** @typedef {Assert>} _Kind */ + return kind +} ``` -Declaration validation must verify that function-local typedefs remain lexical -and do not appear as exported aliases in generated `.d.ts` / `.d.mts` files. +These typedefs stay inside the narrowest function scope that provides the values +they need. Private function-local typedef names keep the leading `_` convention. +Declaration validation must verify that they remain lexical and do not appear as +exported aliases in generated `.d.ts` / `.d.mts` files. #### Type metadata Some TypeScript types are derived from runtime values rather than declared -independently. These values include RTTI definitions, for example: +independently. These values include RTTI definitions: ```ts import type { type } from './meta.f.mjs' @@ -96,7 +124,7 @@ import type { type } from './meta.f.mjs' export type Value = Ts ``` -and ordinary constants whose literal value is used by a type query, for example: +and ordinary constants whose literal value is used by a type query: ```ts import type { statuses } from './meta.f.mjs' @@ -105,66 +133,40 @@ export type Status = typeof statuses[number] ``` Put runtime values whose primary purpose is to define, describe, or derive -type-level information in `meta.f.mjs`. This keeps type metadata distinct from -normal implementation code and from its compile-time TypeScript views: - -```text -meta.f.mjs # runtime metadata used by types -types.ts # public compile-time types -private.ts # private compile-time types -``` +type-level information in `meta.f.mjs`. Values whose primary purpose is normal +program behavior remain in `module.f.mjs` even if their types are reused +incidentally. Both `types.ts` and `private.ts` may depend on `meta.f.mjs` for -`Ts`, `typeof ...`, indexed access over `as const`-style values, and -similar type derivation. These dependencies are still type-only from TypeScript's -point of view: the imported value is mentioned only through a type query, so the -import must be erased. - -All imports in authored TypeScript type files use the named type-only form: +`Ts`, `typeof ...`, indexed access over literal values, and similar +type derivation. All imports in authored TypeScript type files use the named +type-only form: ```ts import type { PublicType } from './types.ts' import type { metadataValue } from './meta.f.mjs' ``` -Do not use a runtime `import { ... }`, `import * as ...`, or side-effect import in -`types.ts` or `private.ts`. This matches the repository rule that authored -TypeScript is type-only source and prevents a type module from acquiring runtime -behavior merely because `typeof` refers to an exported `.f.mjs` value. +Do not use runtime `import { ... }`, namespace imports, or side-effect imports in +`types.ts` or `private.ts`. -`meta.f.mjs` itself is ordinary runtime FunctionalScript source and is packaged -like other required `.f.mjs` modules; it is not a private type artifact merely -because `private.ts` may use it. +`meta.f.mjs` is executable FunctionalScript source and is packaged like other +required `.f.mjs` modules. Node and Deno coverage filters must include it under +the same coverage expectations as `module.f.mjs`. -Do not move a runtime value into `types.ts` or `private.ts` merely to avoid this -dependency: runtime values belong in `.f.mjs`. Values whose primary purpose is -normal program behavior remain in `module.f.mjs` even if their types are reused -incidentally; `meta.f.mjs` is for values whose primary role is type-level -metadata. +#### Dependency rules -`meta.f.mjs` is executable FunctionalScript source, not a declaration-only -companion. Coverage must therefore treat it like `module.f.mjs`: the Node and -Deno coverage filters must include `meta.f.mjs`, and proofs must execute the -metadata code sufficiently for the repository's existing coverage thresholds to -apply. Moving executable values from `module.f.mjs` to `meta.f.mjs` must not make -them disappear from coverage. - -Dependency rules: - -- `module.f.mjs` and `proof.f.mjs` may use both `types.ts` and `private.ts` - through JSDoc `@import`. +- `module.f.mjs` and `proof.f.mjs` may use `types.ts` and `private.ts` through + JSDoc `@import`. - `private.ts` may `import type { ... }` public types from `types.ts`. -- `types.ts` and `private.ts` may `import type { ... }` runtime type metadata from - `meta.f.mjs` for `Ts`, `typeof ...`, and similar derivation. -- all imports in `types.ts` and `private.ts` are named `import type { ... }` - imports; they must not create runtime dependencies. - `types.ts` must not depend on `private.ts`. -- A public exported API must not require a `private.ts` type by name. - -This leaves generated declarations free to describe the public API, including -structural types inferred from exported values/functions, without also exporting -implementation-local file-scope typedef names simply because they were declared -in JSDoc. +- `_` helpers required to express public aliases remain in `types.ts` rather + than creating a `types.ts -> private.ts` edge. +- `types.ts` and `private.ts` may `import type { ... }` runtime metadata from + `meta.f.mjs`. +- all imports in `types.ts` and `private.ts` are named `import type { ... }` + imports and must not create runtime dependencies. +- a public declaration must never depend on the removable `private.ts` module. #### Declaration emission and packaging @@ -173,24 +175,22 @@ types and all `@import` users are checked. Consequently, the existing `tsc --emitDeclarationOnly` pass will also generate `private.d.ts`; `exclude` cannot suppress that output once another program input imports `private.ts`. -Do not require TypeScript to avoid generating that intermediate file. Instead, -make private declaration cleanup the **final step of `prepack`**. `npm pack` -runs `prepack` itself, so an external emit/check/cleanup sequence followed by -`npm pack` would be wrong: `npm pack` would rerun declaration emission and -recreate the deleted `private.d.ts` before selecting package contents. +Do not require TypeScript to avoid generating that intermediate file. Make +private declaration cleanup the **final step of `prepack`**. `npm pack` runs +`prepack` itself, so an external emit/check/cleanup sequence followed by +`npm pack` would recreate the deleted declarations. -The packaging lifecycle should therefore be: +The packaging lifecycle is: 1. `prepack` runs normal declaration emission; 2. `prepack` runs the existing declaration round-trip type-check; 3. as the final `prepack` command, delete every generated `private.d.ts`; -4. `npm pack` selects package contents after `prepack` completes; +4. `npm pack` selects package contents; 5. validate the actual packed artifact: - it contains neither authored `private.ts` nor generated `private.d.ts`; - - every shipped `.d.ts` / `.d.mts` is scanned and must not contain a module - reference to the directory's `private` type module; -6. install the tarball in the existing clean TypeScript consumer and type-check - it as an independent semantic validation. + - every shipped `.d.ts` / `.d.mts` is scanned and must not reference a + directory's `private` type module; +6. install the tarball in the clean TypeScript consumer and type-check it. Conceptually, the current `prepack`: @@ -204,111 +204,77 @@ becomes: tsc --noEmit false --emitDeclarationOnly && tsc && ``` -The cleanup command should be implemented with repository-portable tooling; the -exact command is part of implementation, not this source-layout convention. -Tests should invoke `npm pack` normally so they exercise the real lifecycle, -rather than reproducing `prepack` steps externally. - -The declaration scan should reject the private module rather than only one -particular emitted spelling. For example, `./private.ts`, `./private.d.ts`, or a -future equivalent spelling must all be treated as the same forbidden public -dependency. This is a structural package check over the packed file set and the -contents of all packed declarations, not a check limited to whichever public -entry points the clean consumer happens to import. - -References from shipped declarations to a packaged `meta.f.mjs` are allowed: -unlike `private.ts`, metadata is an intentionally packaged runtime module and -may be required to express public types derived from its exported values. - -The cleanup must operate on generated artifacts only; authored `private.ts` -remains available for source-tree type-checking. Neither `private.ts` nor the -intermediate generated `private.d.ts` is shipped. - -Generated declarations such as `module.f.d.mts` may be produced from source that -uses `private.ts`, but no shipped declaration may depend on the private type -module. If an exported declaration needs a private type, either that type is -actually public and belongs in `types.ts`, or the public declaration must be -expressible without exposing the private type name. The package check must fail -rather than retaining `private.d.ts` to make such a leak resolve. +The cleanup command should use repository-portable tooling. Tests should invoke +`npm pack` normally so they exercise the real lifecycle. + +The declaration scan should reject the private module rather than one particular +specifier spelling (`./private.ts`, `./private.d.ts`, or a future equivalent). +References to packaged `meta.f.mjs` are allowed. ### Tasks - [ ] Document `private.ts` and `meta.f.mjs` beside the existing `types.ts`, - `module.*`, and `proof.*` file conventions. + `module.*`, and `proof.*` conventions. - [ ] Prohibit file-scope JSDoc `@typedef` declarations in `module.f.mjs` and `proof.f.mjs`; allow function-local `@typedef` declarations everywhere. -- [ ] Keep the leading `_` convention for private types, including function-local - private typedefs. +- [ ] Keep the leading `_` convention for every private type name, including + private helpers in `types.ts` and function-local private typedefs. - [ ] Move public file-scope named types from implementation/proof JSDoc into `types.ts`. -- [ ] Move private file-scope named types out of `types.ts`, `module.f.mjs`, and - `proof.f.mjs` into each directory's `private.ts` where applicable. +- [ ] Keep `_` helpers required to express public declarations in `types.ts`; + do not create `types.ts -> private.ts` dependencies. +- [ ] Move other private file-scope named types out of `types.ts`, + `module.f.mjs`, and `proof.f.mjs` into each directory's `private.ts`. - [ ] Keep lexical type-proof typedefs inside the functions whose local values - they inspect; do not force them into `private.ts`. + they inspect. - [ ] Move runtime values whose primary purpose is type derivation into - `meta.f.mjs`; include both RTTI definitions and non-RTTI constants used by - `Ts`, `typeof ...`, or equivalent type queries. -- [ ] Require every import in authored TypeScript type files (`types.ts` and - `private.ts`) to use named `import type { ... }`, including imports from - `meta.f.mjs` used only through `typeof`. + `meta.f.mjs`, including RTTI definitions and non-RTTI literal constants. +- [ ] Require every import in `types.ts` and `private.ts` to use named + `import type { ... }`. - [ ] Update Node coverage selection to include both `module.f.mjs` and - `meta.f.mjs` under the existing 100% thresholds. -- [ ] Update Deno `cov` and `cov-html` include filters to include both - `module.f.mjs` and `meta.f.mjs`. -- [ ] Keep `private.ts` in normal TypeScript type-checking without generating a - runtime JavaScript file for it. -- [ ] Make deletion of generated `private.d.ts` files the final `prepack` step, - after declaration emission and the declaration round-trip check. -- [ ] Exercise cleanup through a normal `npm pack`; do not run cleanup externally - before `npm pack`, because `npm pack` reruns `prepack`. -- [ ] Inspect the `npm pack` artifact and reject any packed `private.ts` or - `private.d.ts` file. -- [ ] Scan every packed `.d.ts` / `.d.mts` and reject any module reference to a - directory's private type module, independent of the exact emitted suffix. -- [ ] Add a fixture where `module.f.mjs` and `proof.f.mjs` use `_`-prefixed types - from `private.ts` without declaring any file-scope `@typedef`; also include - a function-local typedef that depends on a lexical value and verify it does - not escape into generated declarations. Verify declaration emit creates the - intermediate `private.d.ts`, final-`prepack` cleanup removes it, generated - public declarations contain no implementation-local file-scope typedef - exports, and packed-artifact validation finds no private type file or - declaration edge. + `meta.f.mjs` under the existing thresholds. +- [ ] Update Deno `cov` and `cov-html` filters to include both `module.f.mjs` and + `meta.f.mjs`. +- [ ] Keep `private.ts` in normal TypeScript checking without generating runtime + JavaScript for it. +- [ ] Make deletion of generated `private.d.ts` files the final `prepack` step. +- [ ] Exercise cleanup through normal `npm pack`. +- [ ] Inspect the packed artifact and reject any `private.ts` or `private.d.ts`. +- [ ] Scan every packed `.d.ts` / `.d.mts` and reject any dependency on a + directory's private type module. +- [ ] Add a fixture covering all three private-type cases: + - a `_` helper in `types.ts` required by an exported public alias; + - an implementation-private `_` type in `private.ts`; + - a function-local `_` typedef depending on a lexical value. + Verify the first remains self-contained in `types.d.ts`, the second's + intermediate `private.d.ts` is removed, and the third does not escape. - [ ] Extend the fixture with `meta.f.mjs` containing both an RTTI value and a - non-RTTI literal constant used to derive TypeScript types in `types.ts` or - `private.ts`; import both with `import type { ... }`, verify the source tree - and packed consumer resolve both metadata dependencies correctly, and that - executable `meta.f.mjs` code is included in both Node and Deno coverage. + non-RTTI literal constant used through `import type { ... }`, and verify + source checking, packing, clean-consumer resolution, and Node/Deno coverage. - [ ] Verify a clean TypeScript consumer can install the packed tarball and use the public API without any private artifact present. ### Acceptance criteria -- `module.f.mjs` and `proof.f.mjs` contain no file-scope JSDoc `@typedef` - declarations. -- Function-local JSDoc `@typedef` declarations are allowed in any source file and - may depend on lexical values; they do not escape as exported declaration - aliases. -- All public file-scope named TypeScript types live in `types.ts`. -- All private file-scope named TypeScript types live in `private.ts` and keep - their leading `_`; private function-local typedefs also keep `_`. -- Every import in `types.ts` and `private.ts` uses named `import type { ... }`; - these files have no runtime imports, including for `.f.mjs` values referenced - only through `typeof`. -- Runtime values whose primary purpose is to define, describe, or derive - type-level information live in `meta.f.mjs`; this includes RTTI definitions - and non-RTTI constants used by `Ts`, `typeof ...`, and similar - type queries. -- Node and Deno coverage include executable `meta.f.mjs` files under the same - coverage expectations as `module.f.mjs`. -- Generated public declarations do not expose private file-scope named typedefs - merely as a consequence of declaration emission. +- `module.f.mjs` and `proof.f.mjs` contain no file-scope JSDoc `@typedef`. +- Function-local JSDoc `@typedef` declarations are allowed everywhere; private + ones keep `_` and do not escape as exported declaration aliases. +- Public file-scope types live in `types.ts`. +- Private `_` helpers required to express public types also remain in `types.ts` + and are not source exports merely because they are declaration helpers. +- Other private file-scope types live in `private.ts` and keep `_`. +- `types.ts` never depends on `private.ts`. +- Every import in `types.ts` and `private.ts` uses named `import type { ... }`. +- Runtime values primarily used for type derivation live in `meta.f.mjs`. +- Node and Deno coverage include executable `meta.f.mjs` files. - Declaration emission may create `private.d.ts`; the final `prepack` step - removes it, and `npm pack` selects contents only after that cleanup completes. + removes it before package contents are selected. - The packed tarball contains neither `private.ts` nor `private.d.ts`. -- No packed `.d.ts` / `.d.mts` file depends on a directory's private type module, - regardless of the exact emitted module-specifier suffix. -- Required references to packaged `meta.f.mjs` modules remain valid in the - packed artifact and clean consumer. +- No packed declaration depends on a directory's private type module. +- Public declaration helpers retained in `types.ts` remain resolvable from the + shipped `types.d.ts` without any private artifact. +- Required references to packaged `meta.f.mjs` remain valid in the packed + artifact and clean consumer. - A clean TypeScript consumer type-checks successfully against the packed tarball after all private artifacts have been removed. From de513977f92f2317a7ba2747aad35f1fb5837b71 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 08:54:58 -0700 Subject: [PATCH 048/370] todo: make public type relocation breaking --- fjs/todo/separate-private-types.md | 38 +++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 02d1b7fea..80b27c89b 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -168,6 +168,35 @@ the same coverage expectations as `module.f.mjs`. imports and must not create runtime dependencies. - a public declaration must never depend on the removable `private.ts` module. +#### Breaking public type migration + +Moving a public file-scope JSDoc typedef from `module.f.mjs` or `proof.f.mjs` to +`types.ts` changes its published import path. Treat that relocation as an +intentional breaking API change rather than preserving the old type entry point +with compatibility re-exports. + +For example, if consumers previously imported a type from: + +```text +./module.f.mjs +``` + +and the type moves to `types.ts`, its new public type entry point is: + +```text +./types.ts +``` + +The migration must update every repository importer to the new path and record +the breaking change in the changelog. Do not leave compatibility typedefs or +re-exports in `module.f.mjs` merely to preserve the old type-only subpath: that +would reintroduce the file-scope typedef/declaration noise this convention is +intended to remove. + +This breaking rule applies to type entry points, not runtime exports. Moving +runtime values to `meta.f.mjs` requires its own API decision if those values are +publicly imported at runtime. + #### Declaration emission and packaging `private.ts` is source-only and remains in the normal TypeScript program so its @@ -220,7 +249,10 @@ References to packaged `meta.f.mjs` are allowed. - [ ] Keep the leading `_` convention for every private type name, including private helpers in `types.ts` and function-local private typedefs. - [ ] Move public file-scope named types from implementation/proof JSDoc into - `types.ts`. + `types.ts` as a breaking type-API migration; update every repository + importer to the new `types.ts` path and record the break in the changelog. +- [ ] Do not add compatibility typedefs or re-exports to preserve old + `module.f.mjs` type entry points. - [ ] Keep `_` helpers required to express public declarations in `types.ts`; do not create `types.ts -> private.ts` dependencies. - [ ] Move other private file-scope named types out of `types.ts`, @@ -260,6 +292,10 @@ References to packaged `meta.f.mjs` are allowed. - Function-local JSDoc `@typedef` declarations are allowed everywhere; private ones keep `_` and do not escape as exported declaration aliases. - Public file-scope types live in `types.ts`. +- Moving a public type from `module.f.mjs` / `proof.f.mjs` to `types.ts` is an + intentional breaking API change: repository importers use the new path, the + changelog records the break, and no compatibility typedef/re-export preserves + the old type entry point. - Private `_` helpers required to express public types also remain in `types.ts` and are not source exports merely because they are declaration helpers. - Other private file-scope types live in `private.ts` and keep `_`. From 32eec933d0cad748dec8c059f1aa1eee59850a4d Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:00:23 -0700 Subject: [PATCH 049/370] todo: broaden meta constants convention --- fjs/todo/separate-private-types.md | 84 +++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 19 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 80b27c89b..7e13305d3 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -45,7 +45,7 @@ type derivation are needed: ```text module.f.mjs # implementation; no file-scope @typedef proof.f.mjs # proofs; no file-scope @typedef -meta.f.mjs # runtime metadata used to define/derive types +meta.f.mjs # runtime constants used by TypeScript type definitions/proofs types.ts # public types + `_` helpers required to express them private.ts # implementation-private `_` types ``` @@ -60,6 +60,7 @@ public type -> types.ts private `_` helper required by a public type -> types.ts other file-scope private `_` type -> private.ts function-local typedef -> allowed in place +runtime constant used by TypeScript types/proofs -> meta.f.mjs ``` A `_` helper required to define a public type is still private by name and need @@ -115,8 +116,13 @@ exported aliases in generated `.d.ts` / `.d.mts` files. #### Type metadata -Some TypeScript types are derived from runtime values rather than declared -independently. These values include RTTI definitions: +`meta.f.mjs` contains runtime constants that TypeScript type definitions or +file-scope type proofs refer to. The values do **not** need to be RTTI, and they +do not need to exist primarily for type-system purposes. A normal runtime +constant belongs in `meta.f.mjs` when its literal value or inferred type is part +of a TypeScript type definition/proof. + +This includes RTTI definitions: ```ts import type { type } from './meta.f.mjs' @@ -124,7 +130,7 @@ import type { type } from './meta.f.mjs' export type Value = Ts ``` -and ordinary constants whose literal value is used by a type query: +ordinary literal constants: ```ts import type { statuses } from './meta.f.mjs' @@ -132,10 +138,37 @@ import type { statuses } from './meta.f.mjs' export type Status = typeof statuses[number] ``` -Put runtime values whose primary purpose is to define, describe, or derive -type-level information in `meta.f.mjs`. Values whose primary purpose is normal -program behavior remain in `module.f.mjs` even if their types are reused -incidentally. +and runtime tables that are also used by normal implementation code: + +```js +// meta.f.mjs +export const framingKeywords = + /** @type {const} */ (['import', 'const', 'export', 'default', 'from']) +``` + +```ts +// private.ts +import type { framingKeywords } from './meta.f.mjs' + +type _KeywordsAreComplete = + Assert> +``` + +```js +// module.f.mjs +import { framingKeywords } from './meta.f.mjs' +``` + +This is the intended solution for file-scope type proofs over module constants: +move the referenced constant to `meta.f.mjs`, keep its runtime consumers using a +normal JavaScript import, and move the file-scope private proof/type to +`private.ts` (or `types.ts` when it is required by a public declaration). The +constant does not become RTTI merely because it lives in `meta.f.mjs`; `meta` +means that its value participates in the type-level model. + +Do not move arbitrary runtime values to `meta.f.mjs` merely because their type +could theoretically be queried. The trigger is an actual TypeScript type +reference/proof (`typeof`, `Ts`, indexed access, etc.). Both `types.ts` and `private.ts` may depend on `meta.f.mjs` for `Ts`, `typeof ...`, indexed access over literal values, and similar @@ -158,12 +191,15 @@ the same coverage expectations as `module.f.mjs`. - `module.f.mjs` and `proof.f.mjs` may use `types.ts` and `private.ts` through JSDoc `@import`. +- `module.f.mjs` and other runtime modules may import runtime constants normally + from `meta.f.mjs`. - `private.ts` may `import type { ... }` public types from `types.ts`. - `types.ts` must not depend on `private.ts`. - `_` helpers required to express public aliases remain in `types.ts` rather than creating a `types.ts -> private.ts` edge. -- `types.ts` and `private.ts` may `import type { ... }` runtime metadata from - `meta.f.mjs`. +- `types.ts` and `private.ts` may `import type { ... }` constants from + `meta.f.mjs` when those values participate in TypeScript type definitions or + proofs. - all imports in `types.ts` and `private.ts` are named `import type { ... }` imports and must not create runtime dependencies. - a public declaration must never depend on the removable `private.ts` module. @@ -193,9 +229,10 @@ re-exports in `module.f.mjs` merely to preserve the old type-only subpath: that would reintroduce the file-scope typedef/declaration noise this convention is intended to remove. -This breaking rule applies to type entry points, not runtime exports. Moving -runtime values to `meta.f.mjs` requires its own API decision if those values are -publicly imported at runtime. +This breaking rule applies to type entry points, not runtime exports. Moving a +public runtime constant from `module.f.mjs` to `meta.f.mjs` requires its own API +decision: preserve the old runtime entry point with a re-export or make that +runtime move an explicit breaking change and update importers/changelog. #### Declaration emission and packaging @@ -259,8 +296,12 @@ References to packaged `meta.f.mjs` are allowed. `module.f.mjs`, and `proof.f.mjs` into each directory's `private.ts`. - [ ] Keep lexical type-proof typedefs inside the functions whose local values they inspect. -- [ ] Move runtime values whose primary purpose is type derivation into - `meta.f.mjs`, including RTTI definitions and non-RTTI literal constants. +- [ ] Move runtime constants referenced by TypeScript type definitions/proofs + into `meta.f.mjs`, including RTTI definitions, non-RTTI literal constants, + and ordinary runtime tables whose literal/inferred types are asserted. +- [ ] Move file-scope private type proofs over those constants to `private.ts` + (or keep helpers in `types.ts` when required by a public declaration), and + use `import type { ... }` to reference the `meta.f.mjs` values. - [ ] Require every import in `types.ts` and `private.ts` to use named `import type { ... }`. - [ ] Update Node coverage selection to include both `module.f.mjs` and @@ -280,9 +321,10 @@ References to packaged `meta.f.mjs` are allowed. - a function-local `_` typedef depending on a lexical value. Verify the first remains self-contained in `types.d.ts`, the second's intermediate `private.d.ts` is removed, and the third does not escape. -- [ ] Extend the fixture with `meta.f.mjs` containing both an RTTI value and a - non-RTTI literal constant used through `import type { ... }`, and verify - source checking, packing, clean-consumer resolution, and Node/Deno coverage. +- [ ] Extend the fixture with `meta.f.mjs` containing an RTTI value, a non-RTTI + literal constant, and a runtime-used constant whose type is asserted from + `private.ts`; verify runtime imports, type-only imports, source checking, + packing, clean-consumer resolution, and Node/Deno coverage. - [ ] Verify a clean TypeScript consumer can install the packed tarball and use the public API without any private artifact present. @@ -301,7 +343,11 @@ References to packaged `meta.f.mjs` are allowed. - Other private file-scope types live in `private.ts` and keep `_`. - `types.ts` never depends on `private.ts`. - Every import in `types.ts` and `private.ts` uses named `import type { ... }`. -- Runtime values primarily used for type derivation live in `meta.f.mjs`. +- Runtime constants referenced by TypeScript type definitions/proofs live in + `meta.f.mjs`, whether they are RTTI, literal metadata, or ordinary runtime + tables also consumed by implementation code. +- File-scope private proofs over such constants can live in `private.ts` without + exporting implementation locals from `module.f.mjs`. - Node and Deno coverage include executable `meta.f.mjs` files. - Declaration emission may create `private.d.ts`; the final `prepack` step removes it before package contents are selected. From 556e5359243e871a7c246aaaecc68abae6c3b009 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:02:32 -0700 Subject: [PATCH 050/370] todo: make meta moves breaking API changes --- fjs/todo/separate-private-types.md | 47 ++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 7e13305d3..2d60541bb 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -204,14 +204,13 @@ the same coverage expectations as `module.f.mjs`. imports and must not create runtime dependencies. - a public declaration must never depend on the removable `private.ts` module. -#### Breaking public type migration +#### Breaking public API migration -Moving a public file-scope JSDoc typedef from `module.f.mjs` or `proof.f.mjs` to -`types.ts` changes its published import path. Treat that relocation as an -intentional breaking API change rather than preserving the old type entry point -with compatibility re-exports. +Moving public definitions to their dedicated files changes their published +import paths. Treat these relocations as intentional breaking API changes rather +than preserving the old entry points with compatibility re-exports. -For example, if consumers previously imported a type from: +For public types, if consumers previously imported a type from: ```text ./module.f.mjs @@ -223,16 +222,23 @@ and the type moves to `types.ts`, its new public type entry point is: ./types.ts ``` -The migration must update every repository importer to the new path and record -the breaking change in the changelog. Do not leave compatibility typedefs or -re-exports in `module.f.mjs` merely to preserve the old type-only subpath: that -would reintroduce the file-scope typedef/declaration noise this convention is -intended to remove. +For public runtime constants, if consumers previously imported a value from: -This breaking rule applies to type entry points, not runtime exports. Moving a -public runtime constant from `module.f.mjs` to `meta.f.mjs` requires its own API -decision: preserve the old runtime entry point with a re-export or make that -runtime move an explicit breaking change and update importers/changelog. +```text +./module.f.mjs +``` + +and the value moves to `meta.f.mjs`, its new runtime entry point is: + +```text +./meta.f.mjs +``` + +In both cases, update every repository importer to the new path and record the +breaking change in the changelog. Do **not** leave compatibility typedefs, +exports, or re-exports in `module.f.mjs` to preserve the old entry point. The +point of the migration is to make the source/API boundaries explicit rather than +carry aliases from the old layout indefinitely. #### Declaration emission and packaging @@ -299,6 +305,11 @@ References to packaged `meta.f.mjs` are allowed. - [ ] Move runtime constants referenced by TypeScript type definitions/proofs into `meta.f.mjs`, including RTTI definitions, non-RTTI literal constants, and ordinary runtime tables whose literal/inferred types are asserted. +- [ ] Treat moves of public runtime constants to `meta.f.mjs` as breaking API + changes: update every repository runtime importer to the new path and + record the break in the changelog. +- [ ] Do not add compatibility exports or re-exports in `module.f.mjs` for + runtime constants moved to `meta.f.mjs`. - [ ] Move file-scope private type proofs over those constants to `private.ts` (or keep helpers in `types.ts` when required by a public declaration), and use `import type { ... }` to reference the `meta.f.mjs` values. @@ -338,6 +349,10 @@ References to packaged `meta.f.mjs` are allowed. intentional breaking API change: repository importers use the new path, the changelog records the break, and no compatibility typedef/re-export preserves the old type entry point. +- Moving a public runtime constant from `module.f.mjs` to `meta.f.mjs` is an + intentional breaking API change: repository runtime importers use the new + path, the changelog records the break, and no compatibility export/re-export + preserves the old runtime entry point. - Private `_` helpers required to express public types also remain in `types.ts` and are not source exports merely because they are declaration helpers. - Other private file-scope types live in `private.ts` and keep `_`. @@ -365,4 +380,4 @@ References to packaged `meta.f.mjs` are allowed. - [`detect-unexported-types-referenced-by-exported-types.md`](./detect-unexported-types-referenced-by-exported-types.md) — detect private type names that leak through exported types. - [`document-file-type-naming-conventions.md`](./document-file-type-naming-conventions.md) — document the repository's source-file roles. - [`../../todo/migrate-typescript-to-mjs.md`](../../todo/migrate-typescript-to-mjs.md) — current JavaScript/JSDoc implementation migration and `_` private-type convention. -- [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) — declaration emission and clean packed-package validation. +- [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) — declaration emission and clean packed-package validation. \ No newline at end of file From 7368e069c3198a270b3bc1f0c09401ab3fd762d7 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:04:59 -0700 Subject: [PATCH 051/370] todo: reconcile private type policy --- fjs/todo/separate-private-types.md | 109 ++++++++++++++++++++--------- 1 file changed, 74 insertions(+), 35 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 2d60541bb..ba41d438b 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -37,6 +37,42 @@ program; that generated private declaration must be removed before packaging. The leading `_` convention remains useful in both categories: it means the type name itself is private even when the helper must live beside public types. +#### Relationship to the current `_` workaround + +[`../fsc/README.md`](../fsc/README.md) currently defines a deliberate interim +policy for private JSDoc typedefs: until TypeScript supports stripping JSDoc +`@typedef`s with `@internal` plus `stripInternal`, a leading `_` marks an emitted +alias as private by contract even when declaration emit exposes it. The upstream +blocker is +[microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407), +and the waiting strategy is tracked in +[`../../todo/blocked/jsdoc-typedef-strip-internal.md`](../../todo/blocked/jsdoc-typedef-strip-internal.md). + +That policy remains authoritative **until this migration is implemented**. This +TODO intentionally proposes replacing the wait-for-upstream workaround for +file-scope implementation-private types with a structural boundary: + +- file-scope implementation-private named types move to `private.ts`; +- file-scope JSDoc typedefs disappear from implementation/proof modules, so they + no longer leak merely because TypeScript emits them; +- `private.d.ts` is treated as an intermediate package-build artifact and is + removed before packing; +- function-local typedefs remain available for lexical type proofs and do not + need the file-level workaround. + +Physical separation is preferred here because it solves the declaration leak +with tools available today, gives private named types the full TypeScript type +language, and makes the public/private source and package boundaries explicit. +The leading `_` remains the naming convention for private types; this proposal +changes where file-scope private types live, not what `_` means. + +When this TODO is implemented, update `fjs/fsc/README.md` so it no longer presents +leaked file-scope JSDoc typedefs as the intended steady-state convention. Also +revisit `todo/blocked/jsdoc-typedef-strip-internal.md`: delete it if no remaining +supported case needs file-scope private JSDoc typedef stripping, or narrow it to +whatever cases remain. Do not leave two live documents prescribing different +private-type strategies. + ### Proposal Use this directory convention where named types or runtime metadata used for @@ -206,41 +242,30 @@ the same coverage expectations as `module.f.mjs`. #### Breaking public API migration -Moving public definitions to their dedicated files changes their published -import paths. Treat these relocations as intentional breaking API changes rather -than preserving the old entry points with compatibility re-exports. - -For public types, if consumers previously imported a type from: - -```text -./module.f.mjs -``` - -and the type moves to `types.ts`, its new public type entry point is: +Moving a public file-scope JSDoc typedef from `module.f.mjs` or `proof.f.mjs` to +`types.ts` changes its published type import path. Moving a public runtime +constant from `module.f.mjs` to `meta.f.mjs` changes its published runtime import +path. Treat **both** relocations as intentional breaking API changes; do not +preserve the old entry points with compatibility typedefs, exports, or re-exports. -```text -./types.ts -``` - -For public runtime constants, if consumers previously imported a value from: +For types: ```text -./module.f.mjs +./module.f.mjs -> ./types.ts ``` -and the value moves to `meta.f.mjs`, its new runtime entry point is: +For runtime metadata: ```text -./meta.f.mjs +./module.f.mjs -> ./meta.f.mjs ``` -In both cases, update every repository importer to the new path and record the -breaking change in the changelog. Do **not** leave compatibility typedefs, -exports, or re-exports in `module.f.mjs` to preserve the old entry point. The -point of the migration is to make the source/API boundaries explicit rather than -carry aliases from the old layout indefinitely. +The migration must update every repository importer to the new path and record +the breaking change in the changelog. Keeping compatibility aliases or +re-exports in `module.f.mjs` would preserve exactly the mixed responsibilities +this convention is intended to remove. -#### Declaration emission and packaging +### Declaration emission and packaging `private.ts` is source-only and remains in the normal TypeScript program so its types and all `@import` users are checked. Consequently, the existing @@ -287,6 +312,11 @@ References to packaged `meta.f.mjs` are allowed. - [ ] Document `private.ts` and `meta.f.mjs` beside the existing `types.ts`, `module.*`, and `proof.*` conventions. +- [ ] Reconcile the implemented convention with the current private-JSDoc policy: + update `fjs/fsc/README.md` to replace the leaked-file-scope-typedef + workaround, and delete or narrow + `todo/blocked/jsdoc-typedef-strip-internal.md` so the repository has one + authoritative strategy. - [ ] Prohibit file-scope JSDoc `@typedef` declarations in `module.f.mjs` and `proof.f.mjs`; allow function-local `@typedef` declarations everywhere. - [ ] Keep the leading `_` convention for every private type name, including @@ -305,14 +335,12 @@ References to packaged `meta.f.mjs` are allowed. - [ ] Move runtime constants referenced by TypeScript type definitions/proofs into `meta.f.mjs`, including RTTI definitions, non-RTTI literal constants, and ordinary runtime tables whose literal/inferred types are asserted. -- [ ] Treat moves of public runtime constants to `meta.f.mjs` as breaking API - changes: update every repository runtime importer to the new path and - record the break in the changelog. -- [ ] Do not add compatibility exports or re-exports in `module.f.mjs` for - runtime constants moved to `meta.f.mjs`. - [ ] Move file-scope private type proofs over those constants to `private.ts` (or keep helpers in `types.ts` when required by a public declaration), and use `import type { ... }` to reference the `meta.f.mjs` values. +- [ ] Treat moves of public runtime constants to `meta.f.mjs` as breaking API + changes: update every repository runtime importer and the changelog; do not + leave compatibility exports or re-exports in `module.f.mjs`. - [ ] Require every import in `types.ts` and `private.ts` to use named `import type { ... }`. - [ ] Update Node coverage selection to include both `module.f.mjs` and @@ -341,6 +369,9 @@ References to packaged `meta.f.mjs` are allowed. ### Acceptance criteria +- The current `_` leak-tolerance policy is explicitly superseded when this + migration is implemented; `fjs/fsc/README.md` and the blocked `@internal` / + `stripInternal` TODO no longer prescribe a conflicting strategy. - `module.f.mjs` and `proof.f.mjs` contain no file-scope JSDoc `@typedef`. - Function-local JSDoc `@typedef` declarations are allowed everywhere; private ones keep `_` and do not escape as exported declaration aliases. @@ -349,10 +380,10 @@ References to packaged `meta.f.mjs` are allowed. intentional breaking API change: repository importers use the new path, the changelog records the break, and no compatibility typedef/re-export preserves the old type entry point. -- Moving a public runtime constant from `module.f.mjs` to `meta.f.mjs` is an - intentional breaking API change: repository runtime importers use the new - path, the changelog records the break, and no compatibility export/re-export - preserves the old runtime entry point. +- Moving a public runtime constant from `module.f.mjs` to `meta.f.mjs` is also an + intentional breaking API change: repository importers use the new path, the + changelog records the break, and no compatibility export/re-export preserves + the old runtime entry point. - Private `_` helpers required to express public types also remain in `types.ts` and are not source exports merely because they are declaration helpers. - Other private file-scope types live in `private.ts` and keep `_`. @@ -377,7 +408,15 @@ References to packaged `meta.f.mjs` are allowed. ### Related +- [`../fsc/README.md`](../fsc/README.md) — current `_` leak-tolerance policy that + this migration supersedes once implemented. +- [`../../todo/blocked/jsdoc-typedef-strip-internal.md`](../../todo/blocked/jsdoc-typedef-strip-internal.md) + — current wait-for-`@internal`/`stripInternal` strategy; delete or narrow when + this migration lands. +- [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) + — upstream JSDoc `@typedef` stripping limitation that motivated the current + workaround. - [`detect-unexported-types-referenced-by-exported-types.md`](./detect-unexported-types-referenced-by-exported-types.md) — detect private type names that leak through exported types. - [`document-file-type-naming-conventions.md`](./document-file-type-naming-conventions.md) — document the repository's source-file roles. - [`../../todo/migrate-typescript-to-mjs.md`](../../todo/migrate-typescript-to-mjs.md) — current JavaScript/JSDoc implementation migration and `_` private-type convention. -- [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) — declaration emission and clean packed-package validation. \ No newline at end of file +- [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) — declaration emission and clean packed-package validation. From 18c604e8d9aae9e77c5469bef9465b7476af0916 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:17:40 -0700 Subject: [PATCH 052/370] todo: reconcile authored TypeScript policy --- fjs/todo/separate-private-types.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index ba41d438b..85a4ead3b 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -73,6 +73,15 @@ supported case needs file-scope private JSDoc typedef stripping, or narrow it to whatever cases remain. Do not leave two live documents prescribing different private-type strategies. +The migration also changes the repository's authored-TypeScript policy. +[`../AGENTS.md`](../AGENTS.md) currently says that `types.ts` is the only authored +TypeScript in `fjs/`. Once `private.ts` is introduced, update that rule so +`types.ts` and `private.ts` are the only authored TypeScript type-module roles: +`types.ts` owns the public type API and the few `_` helpers required to express +it, while `private.ts` owns implementation-private file-scope types. Both remain +type-only modules whose imports use named `import type { ... }`. Do not leave the +implemented convention contradicting the contributor policy. + ### Proposal Use this directory convention where named types or runtime metadata used for @@ -317,6 +326,10 @@ References to packaged `meta.f.mjs` are allowed. workaround, and delete or narrow `todo/blocked/jsdoc-typedef-strip-internal.md` so the repository has one authoritative strategy. +- [ ] Update `fjs/AGENTS.md` so the authored-TypeScript policy allows exactly the + intended type-module roles: `types.ts` for public types/public-type helpers + and `private.ts` for implementation-private file-scope types. Preserve the + rule that all imports in those files are named `import type { ... }`. - [ ] Prohibit file-scope JSDoc `@typedef` declarations in `module.f.mjs` and `proof.f.mjs`; allow function-local `@typedef` declarations everywhere. - [ ] Keep the leading `_` convention for every private type name, including @@ -372,6 +385,10 @@ References to packaged `meta.f.mjs` are allowed. - The current `_` leak-tolerance policy is explicitly superseded when this migration is implemented; `fjs/fsc/README.md` and the blocked `@internal` / `stripInternal` TODO no longer prescribe a conflicting strategy. +- `fjs/AGENTS.md` no longer says `types.ts` is the only authored TypeScript; + it documents `types.ts` and `private.ts` as the allowed authored TypeScript + type-module roles, with their public/private responsibilities and named + `import type { ... }` import rule. - `module.f.mjs` and `proof.f.mjs` contain no file-scope JSDoc `@typedef`. - Function-local JSDoc `@typedef` declarations are allowed everywhere; private ones keep `_` and do not escape as exported declaration aliases. @@ -410,6 +427,8 @@ References to packaged `meta.f.mjs` are allowed. - [`../fsc/README.md`](../fsc/README.md) — current `_` leak-tolerance policy that this migration supersedes once implemented. +- [`../AGENTS.md`](../AGENTS.md) — current authored-TypeScript policy that must be + updated when `private.ts` becomes an allowed authored type module. - [`../../todo/blocked/jsdoc-typedef-strip-internal.md`](../../todo/blocked/jsdoc-typedef-strip-internal.md) — current wait-for-`@internal`/`stripInternal` strategy; delete or narrow when this migration lands. From 89cb200001ac87dacdb64ab2bfcd2b774d17c6ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 16:22:04 +0000 Subject: [PATCH 053/370] emergent_testing: one normalized result for both runners Second step of `todo/share-browser-console-runner.md`, taken before the `sandbox` step it was originally sequenced after. Sharing `sandbox` means merging the browser's ~150 lines of `Symbol.species` machinery with `fjs t`, which has no equivalent -- that answers the cross-realm question `todo/imports-promises-realms.md` marks as open investigation, and a port is the wrong place to answer it. This step needs no such decision. `TestResult` and `testResult` normalize what a leaf is called, whether it passed, and how long it took. The browser derived all three inline at four sites; `fjs t` derived them again on its way to a printed line. The throw expectation is now applied through the same `invert` in both, so "did this leaf pass" is decided in one place rather than three. A thrown *value* is deliberately not in the record. Describing one needs the value, a serializable report cannot carry one, and the two hosts describe it differently for good reasons -- `fjs t` prints it to a terminal, the browser reads `message`/`stack` for a wire. So the description stays with each host, which is the shape of an extension point rather than an omission, and the type says so. Two differences that shows up are recorded in the todo, not fixed here: `fjs t` keeps no stack for a failure, and for a `throw` proof that returns cleanly the two report different messages while agreeing on the status. `fjs t`'s output is byte-identical. Proofs: `testResult.*` for the rule itself, and `normalizedResultMatchesTheSharedOne` / `expectedThrowStatusMatchesTheSharedOne` comparing real browser results against the shared function rather than against literals. Both mutants are killed -- forcing every status to `passed` fails 14, mangling the name fails 8. Changelog: - `emergent_testing`: `TestResult` and `testResult` normalize one leaf's outcome -- its identity, whether it passed once the `throw` expectation has been applied, and how long it took -- so `fjs t` and the browser runner decide those the same way. The browser's result type is now `TestResult` plus its own `message`/`stack`; `fjs t`'s output is unchanged --- changelog/unreleased/1739.md | 5 ++ fjs/emergent_testing/browser.mjs | 62 ++++++++++++------- fjs/emergent_testing/browser/proof.mjs | 29 ++++++++- fjs/emergent_testing/module.f.mjs | 47 +++++++++++--- fjs/emergent_testing/proof.f.mjs | 35 ++++++++++- .../todo/share-browser-console-runner.md | 62 +++++++++++++++---- fjs/emergent_testing/types.ts | 32 ++++++++++ 7 files changed, 223 insertions(+), 49 deletions(-) create mode 100644 changelog/unreleased/1739.md diff --git a/changelog/unreleased/1739.md b/changelog/unreleased/1739.md new file mode 100644 index 000000000..d5f42c0da --- /dev/null +++ b/changelog/unreleased/1739.md @@ -0,0 +1,5 @@ +- `emergent_testing`: `TestResult` and `testResult` normalize one leaf's + outcome — its identity, whether it passed once the `throw` expectation has + been applied, and how long it took — so `fjs t` and the browser runner decide + those the same way. The browser's result type is now `TestResult` plus its own + `message`/`stack`; `fjs t`'s output is unchanged diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 6d6b3a1e4..3d307525f 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -15,10 +15,12 @@ * * @module * - * @import { _TestAndPath } from './types.ts' + * @import { TestResult, _TestAndPath } from './types.ts' + * @import { Result } from '../types/result/types.ts' */ -import { collectTests, fmtImport, fmtPath } from './module.f.mjs' +import { collectTests, testResult } from './module.f.mjs' +import { error as errorResult, invert, ok } from '../types/result/module.f.mjs' /** @type {(value: unknown) => string} */ const text = value => { @@ -58,23 +60,16 @@ const errorDetails = error => { } /** - * `name` is the test's identity, and it is deliberately not built here: it comes - * from `fmtImport`, the same function `fjs t` prints its result lines with, so - * the two runners name a leaf identically — - * `import("./a.proof.f.mjs").proof.x()` in both. A page that invented its own - * spelling would produce reports that cannot be diffed against the console - * runner's, which is the visible half of the two runners having drifted apart. + * A leaf's outcome as the page reports it: the shared {@link TestResult} — + * identity, status and duration, decided by `testResult` rather than here — plus + * the two fields only a browser report needs. * - * It is a field rather than something the renderer derives, because `module` - * and `path` cannot always be recombined into one: a module-level failure and a - * proof exported as a bare function both carry an empty `path`, and only the - * code that produced the result knows which it had. + * `message` and `stack` are the browser's own part, and stay outside the shared + * record for the reason `TestResult` gives: describing a thrown value needs the + * value, a serializable report cannot carry one, and `fjs t` describes it + * differently because it is writing to a terminal rather than to a wire. * - * `path` stays for the consumers that already read it. It is now redundant with - * `name` for every leaf, and belongs in the report-shape decision this issue's - * todo tracks rather than in this change. - * - * @typedef {{ readonly module: string, readonly path: string, readonly name: string, readonly status: string, readonly duration: number, readonly message?: string, readonly stack?: string }} _BrowserTestResult + * @typedef {TestResult & { readonly message?: string, readonly stack?: string }} _BrowserTestResult */ /** @typedef {{ readonly status: string, readonly browser: string, readonly totals: { readonly tests: number, readonly passed: number, readonly failed: number }, readonly duration: number, readonly results: readonly _BrowserTestResult[] }} BrowserTestReport */ @@ -197,15 +192,34 @@ const runPromise = (value, fulfilled, rejected) => { } } +/** + * A failure of a whole module — one that will not link, or whose `proof` export + * cannot be enumerated. It does not go through `testResult`, and that is the + * point: there is no leaf here, so there is no path and no `fmtImport` name to + * build. What is known about it is its source, so its source is its name. + * + * @type {(source: string, duration: number, message: string, stack: string) => _BrowserTestResult} + */ +const moduleFailure = (source, duration, message, stack) => ({ + module: source, path: '', name: source, status: 'failed', duration, message, stack, +}) + /** @type {(module: string, path: readonly (string | null)[], throws: boolean, fn: () => unknown, result: (result: _BrowserTestResult) => void) => Promise} */ const runOne = (module, path, throws, fn, result) => { const start = performance.now() - const name = fmtImport(module, path) + // The throw expectation is applied with the same `invert` the console + // runner's `defaultTest` uses, and the status is then read off the result by + // the same `testResult`. Both runners therefore answer "did this leaf pass" + // in one place — the rule that used to be spelled out at four sites here and + // once again over there. + /** @type {(o: Result, duration: number) => TestResult} */ + const leaf = (o, duration) => + testResult(module, path, { result: throws ? invert(o) : o, duration }) /** @type {(value: unknown) => Promise | readonly _BrowserTestResult[]} */ const passed = value => { const duration = performance.now() - start if (throws) { - const failure = { module, path: fmtPath(path), name, status: 'failed', duration, + const failure = { ...leaf(ok(value), duration), message: 'Expected the proof to throw', stack: '' } result(failure) return [failure] @@ -224,7 +238,7 @@ const runOne = (module, path, throws, fn, result) => { return Promise.all(children.map(([childPath, child]) => runOne(module, childPath, child.throws, child.fn, result) )).then(results => { - const success = { module, path: fmtPath(path), name, status: 'passed', duration } + const success = leaf(ok(value), duration) result(success) return [success, ...results.flat()] }) @@ -233,12 +247,12 @@ const runOne = (module, path, throws, fn, result) => { const failed = error => { const duration = performance.now() - start if (throws) { - const success = { module, path: fmtPath(path), name, status: 'passed', duration } + const success = leaf(errorResult(error), duration) result(success) return [success] } const [message, stack] = errorDetails(error) - const failure = { module, path: fmtPath(path), name, status: 'failed', duration, message, stack } + const failure = { ...leaf(errorResult(error), duration), message, stack } result(failure) return [failure] } @@ -284,7 +298,7 @@ export const runBrowserProofs = (modules, result = () => undefined) => { /** @type {(module: string, error: unknown) => () => Promise} */ const unreadable = (module, error) => () => { const [message, stack] = errorDetails(error) - const failure = { module, path: '', name: module, status: 'failed', duration: 0, message, stack } + const failure = moduleFailure(module, 0, message, stack) announce(failure) return Promise.resolve([failure]) } @@ -391,7 +405,7 @@ export const startBrowserTestSources = (root, sources, importer) => { return publish(root, Promise.resolve(reportOf('infrastructure-error', duration, rejected.map(({ source, error }) => { const [message, stack] = errorDetails(error) - return { module: source, path: '', name: source, status: 'failed', duration, message, stack } + return moduleFailure(source, duration, message, stack) })))) } return startBrowserTests(root, loadedModules.flatMap(module => diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index d0230ec1e..c8776f55b 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -11,7 +11,8 @@ import { runInNewContext } from 'node:vm' import { assert, assertEq, assertNotNullish, assertStructurallySame } from '../../asserts/module.f.mjs' import { renderBrowserReport, runBrowserProofs, startBrowserTests, startBrowserTestSources } from '../browser.mjs' -import { fmtImport } from '../module.f.mjs' +import { fmtImport, testResult } from '../module.f.mjs' +import { error, ok } from '../../types/result/module.f.mjs' /** @typedef {{ readonly tag: string, attributes: ReadonlyMap, readonly ownerDocument: _Document, textContent: string, children: readonly _Element[], readonly setAttribute: (name: string, value: string) => void, readonly removeAttribute: (name: string) => void, readonly querySelector: (selector: string) => _Element | null, readonly replaceChildren: (...nodes: readonly _Element[]) => void, readonly append: (node: _Element) => void }} _Element */ /** @typedef {{ defaultView: _View | null, readonly createElement: (tag: string) => _Element }} _Document */ @@ -117,6 +118,32 @@ export const proof = { assertEq(report.results[1]?.name, fmtImport('proof', ['nested', null, 'child'])) assertEq(report.results[1]?.name, 'import("proof").proof.nested().child()') }, + // The page does not build a leaf's identity, status or duration itself: it + // asks `testResult`, which is what the console runner asks. Comparing a + // real browser result against that function — rather than against a literal + // — is what makes the two runners' agreement a fact about shared code. + normalizedResultMatchesTheSharedOne: async () => { + const report = await run({ passes: () => undefined, fails: () => { throw 'boom' } }) + const [first, second] = report.results + assertNotNullish(first) + assertNotNullish(second) + assertStructurallySame( + { ...first }, + testResult('proof', ['passes'], { result: ok(undefined), duration: first.duration })) + assertStructurallySame( + { ...second, message: undefined, stack: undefined }, + { ...testResult('proof', ['fails'], { result: error('boom'), duration: second.duration }), + message: undefined, stack: undefined }) + assertEq(second.status, 'failed') + }, + // The expectation is inverted through the same `invert` the console runner + // uses, so a proof that was supposed to throw and did is a pass in both. + expectedThrowStatusMatchesTheSharedOne: async () => { + const report = await run({ throw: { boom: () => { throw 'expected' } } }) + assertEq(report.results[0]?.status, 'passed') + assertEq(report.results[0]?.status, + testResult('proof', ['throw', 'boom'], { result: ok('expected'), duration: 0 }).status) + }, // A module that cannot be enumerated has no leaf to name, and an empty // `path` does not distinguish it from a proof exported as a bare function. // The module is what is known, so the module is the name. diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index a32868040..9421738ef 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -13,7 +13,7 @@ * @import { Operation } from '../effects/types.ts' * @import { Effect, NotImplemented } from '../effects/types.ts' * @import { LoadModuleOperations, ModuleMap } from '../dev/types.ts' - * @import { TestFn, TestEntry, TestSet, Path, Reporter, _TestState, _TestAndPath } from './types.ts' + * @import { TestFn, TestEntry, TestSet, Path, Reporter, TestResult, _TestState, _TestAndPath } from './types.ts' * @import { All, Await, Env, IoChannel, NodeProgram, NodeProgramOptions, Program, Sandbox, SandboxResult, Test, TestContext, Write, WriteConsoles } from '../effects/node/types.ts' */ @@ -369,9 +369,33 @@ export const ghEscape = s => export const defaultTest = (file, path, { fn, throws }) => mapStep(sandbox(fn), r => throws ? { ...r, result: invert(r.result) } : r) -/** @type {(file: string, path: Path, color: string, label: string, duration: number) => string} */ -const fmtResultLine = (file, path, color, label, duration) => - `${fmtImport(file, path)}: ${color}${label}${reset}, ${timeFormat(duration)}` +/** + * Normalizes one leaf's outcome: its identity, whether it passed, and how long + * it took. + * + * `r` is the result *after* the throw expectation has been applied — what + * {@link defaultTest} answers — so `ok` means the leaf did what it was supposed + * to, whether that was returning or throwing. Inverting first and normalizing + * second is what lets one status rule serve both cases. + * + * Every runner builds its report through this, so "what is this test called" + * and "did it pass" are answered once rather than once per host. What a runner + * does with the answer — a coloured line, a row in a page, a JSON record — is + * its own. + * + * @type {(file: string, path: Path, r: SandboxResult) => TestResult} + */ +export const testResult = (file, path, { result: [s], duration }) => ({ + module: file, + path: fmtPath(path), + name: fmtImport(file, path), + status: s === 'ok' ? 'passed' : 'failed', + duration, +}) + +/** @type {(r: TestResult, color: string, label: string) => string} */ +const fmtResultLine = ({ name, duration }, color, label) => + `${name}: ${color}${label}${reset}, ${timeFormat(duration)}` /** * The terminal/GitHub reporter used by `fjs t`. Output goes through @@ -399,17 +423,20 @@ export const defaultReporter = options => { const isGitHub = options.env['GITHUB_ACTIONS'] !== undefined return { // https://github.com/OndraM/ci-detector/blob/main/src/Ci/GitHubActions.php - result: (file, path, { result: [s, v], duration }, throws) => - s === 'ok' - ? csiLog(fmtResultLine(file, path, fgGreen, 'ok', duration) + (throws ? ' # EXPECTED TO THROW' : '')) + result: (file, path, r, throws) => { + const t = testResult(file, path, r) + const v = r.result[1] + return t.status === 'passed' + ? csiLog(fmtResultLine(t, fgGreen, 'ok') + (throws ? ' # EXPECTED TO THROW' : '')) : isGitHub - ? csiError(`::error file=${file},line=1,title=${ghEscape(fmtImport(file, path))}::${ghEscape(String(v))}`) + ? csiError(`::error file=${file},line=1,title=${ghEscape(t.name)}::${ghEscape(String(v))}`) // `step`, so the detail line is attempted only when the // header line was written: two halves of one report, and // half of it is worse than none. : step( - csiError(fmtResultLine(file, path, fgRed, 'error', duration)), - () => csiError(`${fgRed}${v}${reset}`)), + csiError(fmtResultLine(t, fgRed, 'error')), + () => csiError(`${fgRed}${v}${reset}`)) + }, summary: (pass, fail, time) => { const fgFail = fail === 0 ? fgGreen : fgRed return step( diff --git a/fjs/emergent_testing/proof.f.mjs b/fjs/emergent_testing/proof.f.mjs index ae074d667..8712f282b 100644 --- a/fjs/emergent_testing/proof.f.mjs +++ b/fjs/emergent_testing/proof.f.mjs @@ -15,7 +15,7 @@ import { assert, assertEq, todo } from '../asserts/module.f.mjs' import { testAll, fmtPath, fmtTerm, fmtImport, ghEscape, isInteger, isIdentifier, registerModule, parseTestSet, - defaultTest, main, register, + defaultTest, main, register, testResult, } from './module.f.mjs' import { run as mockRun } from '../effects/mock/module.f.mjs' import { shouldLoad } from '../dev/module.f.mjs' @@ -622,7 +622,40 @@ const defaultReporterExpectedToThrow = () => { assert(stdout.includes('# EXPECTED TO THROW'), stdout) } +/** + * `testResult` is where every runner decides what a leaf is called and whether + * it passed, so these pin both. + * + * The result it takes is the one *after* the throw expectation has been + * applied, which is why an expected throw does not appear here: inverting is + * `defaultTest`'s job and `invert`'s rule, and this reads whatever that + * produced. + */ +const testResultProofs = { + passes: () => { + const t = testResult('./a.f.mjs', ['x'], { result: ok(1), duration: 0.5 }) + assertEq(t.status, 'passed') + assertEq(t.duration, 0.5) + assertEq(t.module, './a.f.mjs') + }, + fails: () => { + const t = testResult('./a.f.mjs', ['x'], { result: error('boom'), duration: 2 }) + assertEq(t.status, 'failed') + }, + // The identity and the key chain come from the same two functions the + // console runner formats its own output with, so a runner cannot spell + // either of them its own way by building this record itself. + namesTheLeaf: () => { + const path = ['nested', null, 'a.b'] + const t = testResult('./a.f.mjs', path, { result: ok(undefined), duration: 0 }) + assertEq(t.name, fmtImport('./a.f.mjs', path)) + assertEq(t.name, 'import("./a.f.mjs").proof.nested()["a.b"]()') + assertEq(t.path, fmtPath(path)) + }, +} + export const proof = { + testResult: testResultProofs, throw: { registerBodyPanicsOnUndispatchableEffect, }, diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index a5b619bef..7e5d46c92 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -111,28 +111,62 @@ and is reviewable without the next one. function `fjs t` prints its result lines with, so the two runners spell a test identically. This is the smallest possible piece of the issue and also its most visible symptom. -- [ ] **2. One `sandbox`.** Executing a proof body — the clock either side, the +- [x] **2. One normalized result.** `TestResult` and `testResult` in the shared + module: a leaf's identity, status and duration, decided once. The throw + expectation is applied through the same `invert` both runners now use, so + "did this leaf pass" has one answer. Describing a *thrown value* stayed + with each host, deliberately — see below. +- [ ] **3. One `sandbox`.** Executing a proof body — the clock either side, the `try`/`catch`, and the rule that only an actual `Promise` is awaited — is the operation both runners must agree on exactly, and the one place where - they currently do not. Decide the cross-realm question - ([imports, promises and realms](imports-promises-realms.md)) as part of - it, or record the decision, but do not let a port make it silently. -- [ ] **3. Common effects.** Move the host-independent operations (`all`, + they currently do not. **This step is blocked on a decision, not on + work**: the browser carries ~150 lines of `Symbol.species` machinery that + `fjs t` has no equivalent for, so merging the two answers the cross-realm + question in [imports, promises and realms](imports-promises-realms.md) — + which that file marks as investigation. Settle it there first. Doing it + inside a port is how the last attempt lost a defence nobody chose to + lose. +- [ ] **4. Common effects.** Move the host-independent operations (`all`, `await`, `fetch`, `import`, `now`, `sandbox`) out of `effects/node` into a shared module that `effects/node` re-exports unchanged, so nothing has to move with them. -- [ ] **4. A browser interpreter** for exactly those operations, with no +- [ ] **5. A browser interpreter** for exactly those operations, with no scheduling policy of its own. -- [ ] **5. One reporter.** A normalized result the page and the terminal both - render, with no DOM and no terminal text in it. -- [ ] **6. One skeleton.** The page's proof-tree walk is deleted and the shared +- [ ] **6. One reporter.** The event stream — a leaf landed, a run ended — + that both hosts subscribe to. Step 2 gave them the *value*; this gives + them the seam it travels through, and it is what + [report a test's name before running it](report-before-running.md) + needs before a start event can exist. +- [ ] **7. One skeleton.** The page's proof-tree walk is deleted and the shared traversal runs it. -- [ ] **7. The layout move**, and the website preparation program. +- [ ] **8. The layout move**, and the website preparation program. -Steps 2 and 6 are the ones that change behaviour, so they are the ones to keep +Steps 3 and 7 are the ones that change behaviour, so they are the ones to keep smallest. Anything a step reveals goes to an issue and is fixed for both runners later, never inside the step. +**What step 2 revealed, recorded rather than fixed.** With the status shared, +two differences in *describing* a failure are now visible, and both are left +alone on purpose: + +- `fjs t` reports a thrown value by printing it (`String(v)`) and keeps no + stack; the browser reads `message` and `stack` off it, because its report has + to survive a wire hop. Both need the raw value, and a serializable record + cannot carry one — so the description is each host's part, and `TestResult` + says so where a reader will look. +- For a proof marked `throw` that returns cleanly, `fjs t` reports the returned + *value* as the error while the browser reports the fixed string + `Expected the proof to throw`. The two agree on the status, which is what + step 2 shares; they disagree on the message, which belongs with the point + above. + +Note also that `testResult` now sits inside `fjs t`'s own reporting path, so a +defect in it can mislabel the very failures it causes — a mutation forcing every +status to `passed` prints `ok` on failing lines. The pass/fail counts come from +the walk's state rather than from the reporter, so they stay honest and the +summary still reports the failures. Worth remembering when reading output while +changing this function. + ### Preliminary design Share semantics, not host mechanics. The console runner should keep using the @@ -277,8 +311,10 @@ are shared. `nameMatchesTheConsoleRunner` pins it to that function rather than to a spelling. Its `path` field is now redundant with `name` for every leaf and should go when the report shape is decided. -- [ ] Define normalized leaf, progress, infrastructure-error, totals, and report - values without terminal or DOM fields. +- [x] Define a normalized leaf value without terminal or DOM fields: + `TestResult`, built by `testResult`, carrying identity, status and + duration. Progress, infrastructure-error, totals and report values are + still each host's own. - [ ] Decide whether browser import/time/yield/publication justify `fjs/effects/browser/`; document the decision before adding operations. - [ ] Move static proof discovery and `_browser-suite.mjs` generation into diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index a3274ae6a..37fd0ff9e 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -36,6 +36,38 @@ export type TestSet = TestEntry | readonly (readonly [string, unknown])[] */ export type Path = readonly (string | null)[] +/** Whether a leaf passed, after the throw expectation has been applied. */ +export type TestStatus = 'passed' | 'failed' + +/** + * One leaf's outcome, normalized: what ran, whether it passed, and how long it + * took, with no terminal escape codes and no DOM node in it. + * + * It exists so that every runner decides those three things the same way. The + * console runner and the browser runner each used to derive them inline — one + * on its way to a printed line, the other on its way to a serializable report — + * and a status is exactly the kind of small decision that drifts unnoticed when + * it is made twice. + * + * **A thrown value is deliberately absent.** Describing one is not a decision + * every host can share: the browser's report must survive a wire hop, so it + * reads `message` and `stack` off the value, while `fjs t` prints the value + * itself and keeps the stack the panic would have shown. Both need the raw + * value to do that, and a raw value cannot live in a serializable record. So + * the description stays with each host and this carries the part they agree + * on — the shape of an extension point, not an omission. + */ +export type TestResult = { + /** The module key the leaf was discovered in, relative to the run's root. */ + readonly module: string + /** The key chain within that module's `proof` export, as `fmtPath` renders it. */ + readonly path: string + /** The identity `fmtImport` gives the leaf — the same string in every runner. */ + readonly name: string + readonly status: TestStatus + readonly duration: number +} + /** * Receives semantic test-run events. Each method is the runner's notification * of an event; the reporter decides how to render it (terminal, GitHub From cb8913d3311960986a1667f2b168429b3bd0556b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 16:22:36 +0000 Subject: [PATCH 054/370] changelog: rename the entry to this PR's number --- changelog/unreleased/{1739.md => 1741.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/unreleased/{1739.md => 1741.md} (100%) diff --git a/changelog/unreleased/1739.md b/changelog/unreleased/1741.md similarity index 100% rename from changelog/unreleased/1739.md rename to changelog/unreleased/1741.md From 985bb2dd61125fc5cae58c690f8ddf4ce1a05157 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:29:57 -0700 Subject: [PATCH 055/370] private.ts --- fjs/asn.1/module.f.mjs | 27 +-------------------------- fjs/asn.1/private.ts | 22 ++++++++++++++++++++++ fjs/bnf/data/module.f.mjs | 7 ++----- fjs/bnf/data/private.ts | 9 +++++++++ 4 files changed, 34 insertions(+), 31 deletions(-) create mode 100644 fjs/asn.1/private.ts create mode 100644 fjs/bnf/data/private.ts diff --git a/fjs/asn.1/module.f.mjs b/fjs/asn.1/module.f.mjs index 35b997354..168aa1e7e 100644 --- a/fjs/asn.1/module.f.mjs +++ b/fjs/asn.1/module.f.mjs @@ -5,6 +5,7 @@ * @module * * @import { Unpacked, Vec } from '../types/bit_vec/types.ts' + * @import { _ParsedTag, _Round8 } from './private.ts' * @import { ObjectIdentifier, Raw, Record, Sequence, SupportedRecord, _Tag } from './types.ts' */ @@ -32,29 +33,10 @@ const pop8 = pop(8n) // tag -/** - * @typedef {| - * 0b000_00000n | - * 0b001_00000n | - * 0b010_00000n | - * 0b011_00000n | - * 0b100_00000n | - * 0b101_00000n | - * 0b110_00000n | - * 0b111_00000n - * } _ClassPc - */ - const classPcMask = 0b111_00000n const tagNumberMask = 0b000_11111n -/** - * Note: the tag number (the second parameter) can be arbitrarily large, - * so we can't just use a single byte to represent it. - * @typedef {readonly[_ClassPc, bigint]} _ParsedTag - */ - /** @type {([classPc, number]: _ParsedTag) => Vec} */ const parsedTagEncode = ([classPc, number]) => { const [firstByteNumber, rest] = number < tagNumberMask @@ -140,13 +122,6 @@ export const constructedSet = 0x31n // constructed | set // -/** - * @typedef {{ - * readonly byteLen: bigint - * readonly v: Vec - * }} _Round8 - */ - /** @type {(_: Unpacked) => _Round8} */ const round8 = ({ length, uint }) => { const byteLen = divUp8(length) diff --git a/fjs/asn.1/private.ts b/fjs/asn.1/private.ts new file mode 100644 index 000000000..20c339ff4 --- /dev/null +++ b/fjs/asn.1/private.ts @@ -0,0 +1,22 @@ +import type { Vec } from "../types/bit_vec/types.ts" + +export type _ClassPc = | + 0b000_00000n | + 0b001_00000n | + 0b010_00000n | + 0b011_00000n | + 0b100_00000n | + 0b101_00000n | + 0b110_00000n | + 0b111_00000n + +/** + * Note: the tag number (the second parameter) can be arbitrarily large, + * so we can't just use a single byte to represent it. + */ +export type _ParsedTag = readonly[_ClassPc, bigint] + +export type _Round8 = { + readonly byteLen: bigint + readonly v: Vec +} diff --git a/fjs/bnf/data/module.f.mjs b/fjs/bnf/data/module.f.mjs index ffb97fc3f..03dc91447 100644 --- a/fjs/bnf/data/module.f.mjs +++ b/fjs/bnf/data/module.f.mjs @@ -16,6 +16,7 @@ * @import { DataRule, Rule as FRule, Sequence as FSequence } from '../types.ts' * @import { StringMap } from '../../types/object/types.ts' * @import { StringSet } from '../../types/string_set/types.ts' + * @import { _EmptyTagMap, _FRuleMap, _NewRule } from './private.ts' * @import { EmptyTag, Repeat, Rule, RuleSet, Sequence, Variant } from './types.ts' */ @@ -37,7 +38,7 @@ import { contains, set } from '../../types/string_set/module.f.mjs' */ export const isRepeat = rule => typeof rule === 'string' -/** @typedef {StringMap} _EmptyTagMap */ + /** @type {(map: _EmptyTagMap) => (rule: Rule) => EmptyTag} */ const emptyTagOf = map => rule => { @@ -102,8 +103,6 @@ export const emptyTagMap = ruleSet => { // -/** @typedef {StringMap} _FRuleMap */ - const { entries } = Object /** @type {(map: _FRuleMap) => (fr: FRule) => string | undefined} */ @@ -127,8 +126,6 @@ const newName = (map, name) => { return result } -/** @typedef {(m: _FRuleMap) => readonly [_FRuleMap, RuleSet, Rule]} _NewRule */ - /** @type {(list: FSequence) => _NewRule} */ const sequence = list => map => { /** @type {Sequence} */ diff --git a/fjs/bnf/data/private.ts b/fjs/bnf/data/private.ts new file mode 100644 index 000000000..e5a16eadf --- /dev/null +++ b/fjs/bnf/data/private.ts @@ -0,0 +1,9 @@ +import type { StringMap } from "../../types/object/types.ts" +import type { EmptyTag, Rule, RuleSet } from "./types.ts" +import type { Rule as FRule } from '../types.ts' + +export type _EmptyTagMap = StringMap + +export type _FRuleMap = StringMap + +export type _NewRule = (m: _FRuleMap) => readonly [_FRuleMap, RuleSet, Rule] From 304e544bea15cca3d3954b326d9f736c0377cbc0 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:34:08 -0700 Subject: [PATCH 056/370] todo: define public declaration closure --- fjs/todo/separate-private-types.md | 500 ++++++++++++----------------- 1 file changed, 201 insertions(+), 299 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 85a4ead3b..c827be698 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -5,115 +5,55 @@ ### Problem -FunctionalScript directories currently mix private types with implementation and -public type declarations: +FunctionalScript currently mixes named types with implementation/proof source: ```text -module.f.mjs # implementation + private JSDoc types -proof.f.mjs # proofs + private JSDoc types -types.ts # public type API + private helper types +module.f.mjs # implementation + file-scope JSDoc typedefs +proof.f.mjs # proofs + file-scope JSDoc typedefs +types.ts # public types + private helpers ``` -Private types already use a leading `_` by convention, but their location still -creates declaration and package noise. In particular, file-scope JSDoc -`@typedef`s in `module.f.mjs` and `proof.f.mjs` escape into generated `.d.mts` -files: TypeScript emits them as exported type aliases even when they were -intended to be private. +TypeScript declaration emit turns file-scope JSDoc `@typedef`s into exported +aliases, so implementation-private names leak into generated `.d.mts` files. +The existing leading-`_` convention marks those names private by contract, but +the declarations still contain noise and make the source/package boundary less +clear. -There are two different kinds of private file-scope type, and the convention must -not confuse them: - -1. **public-type helpers** such as `_Tuple` that are required to express an - exported type such as `Tuple`; these must stay with the public declaration - graph in `types.ts`; -2. **implementation-private types** used only by implementation/proofs; these - belong in `private.ts`. - -Moving the second category out of implementation/proof files removes the JSDoc -typedef leakage structurally. TypeScript will still emit a declaration for an -imported `private.ts`, because that source file is part of the declaration -program; that generated private declaration must be removed before packaging. - -The leading `_` convention remains useful in both categories: it means the type -name itself is private even when the helper must live beside public types. - -#### Relationship to the current `_` workaround - -[`../fsc/README.md`](../fsc/README.md) currently defines a deliberate interim -policy for private JSDoc typedefs: until TypeScript supports stripping JSDoc -`@typedef`s with `@internal` plus `stripInternal`, a leading `_` marks an emitted -alias as private by contract even when declaration emit exposes it. The upstream -blocker is -[microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407), -and the waiting strategy is tracked in -[`../../todo/blocked/jsdoc-typedef-strip-internal.md`](../../todo/blocked/jsdoc-typedef-strip-internal.md). - -That policy remains authoritative **until this migration is implemented**. This -TODO intentionally proposes replacing the wait-for-upstream workaround for -file-scope implementation-private types with a structural boundary: - -- file-scope implementation-private named types move to `private.ts`; -- file-scope JSDoc typedefs disappear from implementation/proof modules, so they - no longer leak merely because TypeScript emits them; -- `private.d.ts` is treated as an intermediate package-build artifact and is - removed before packing; -- function-local typedefs remain available for lexical type proofs and do not - need the file-level workaround. - -Physical separation is preferred here because it solves the declaration leak -with tools available today, gives private named types the full TypeScript type -language, and makes the public/private source and package boundaries explicit. -The leading `_` remains the naming convention for private types; this proposal -changes where file-scope private types live, not what `_` means. - -When this TODO is implemented, update `fjs/fsc/README.md` so it no longer presents -leaked file-scope JSDoc typedefs as the intended steady-state convention. Also -revisit `todo/blocked/jsdoc-typedef-strip-internal.md`: delete it if no remaining -supported case needs file-scope private JSDoc typedef stripping, or narrow it to -whatever cases remain. Do not leave two live documents prescribing different -private-type strategies. - -The migration also changes the repository's authored-TypeScript policy. -[`../AGENTS.md`](../AGENTS.md) currently says that `types.ts` is the only authored -TypeScript in `fjs/`. Once `private.ts` is introduced, update that rule so -`types.ts` and `private.ts` are the only authored TypeScript type-module roles: -`types.ts` owns the public type API and the few `_` helpers required to express -it, while `private.ts` owns implementation-private file-scope types. Both remain -type-only modules whose imports use named `import type { ... }`. Do not leave the -implemented convention contradicting the contributor policy. +The goal is to give every file-scope named type a deliberate home while keeping +public declarations self-contained. ### Proposal -Use this directory convention where named types or runtime metadata used for -type derivation are needed: +Use this directory convention where needed: ```text module.f.mjs # implementation; no file-scope @typedef proof.f.mjs # proofs; no file-scope @typedef -meta.f.mjs # runtime constants used by TypeScript type definitions/proofs -types.ts # public types + `_` helpers required to express them -private.ts # implementation-private `_` types +meta.f.mjs # runtime constants referenced by TypeScript types/proofs +types.ts # public declaration closure +private.ts # other implementation-private file-scope types ``` -`module.f.mjs` and `proof.f.mjs` may use JSDoc annotations and `@import`, but -must not declare file-scope named types with `@typedef`. +Private type names continue to start with `_`. + +#### Public declaration closure -The placement rule is based on the type dependency graph, not only visibility: +`types.ts` is primarily the public type API, but it may contain private `_` +helpers when they are required to express any shipped public declaration. +"Public declaration" includes both exported type aliases and declarations of +exported runtime values/functions. + +The placement rule is: ```text -public type -> types.ts -private `_` helper required by a public type -> types.ts -other file-scope private `_` type -> private.ts -function-local typedef -> allowed in place -runtime constant used by TypeScript types/proofs -> meta.f.mjs +public type -> types.ts +private `_` helper used by any public declaration -> types.ts +other file-scope private `_` type -> private.ts +function-local typedef -> allowed in place +runtime constant referenced by TS types/proofs -> meta.f.mjs ``` -A `_` helper required to define a public type is still private by name and need -not be exported from `types.ts`. Keeping it there allows TypeScript to emit a -self-contained public declaration module. Moving it to `private.ts` would make a -shipped declaration depend on a module that packaging deliberately removes. - -For example: +For example, a helper used by a public type stays in `types.ts`: ```ts type _Tuple = @@ -122,19 +62,32 @@ type _Tuple = export type Tuple = _Tuple ``` -`_Tuple` stays in `types.ts`: it is private, but it is part of the implementation -of the public `Tuple` declaration. By contrast, a `_State` used only to annotate -`module.f.mjs` belongs in `private.ts`. +The same rule applies when a helper appears in an exported value declaration. +For example, if declaration emit for an exported `find` contains: -`types.ts` must never import `private.ts`. If moving a private alias out of -`types.ts` would create such an edge, that is evidence that the alias is a -public-type helper and should remain in `types.ts`. +```ts +export const find: (cmp: Cmp) => + (value: T) => (array: _SortedArray) => T | null +``` + +then `_SortedArray` is part of the public declaration closure and must remain in +`types.ts` (or be inlined into the public declaration). Moving it to +`private.ts` would make a shipped declaration depend on a declaration module +that packaging removes. + +`types.ts` must never import `private.ts`. If moving a private helper to +`private.ts` would create a `types.ts -> private.ts` edge or cause any generated +public declaration to reference `private.ts`, keep or inline that helper in +`types.ts` instead. + +This should keep `private.ts` uncommon in `types.ts`-heavy modules: it is for +implementation-private file-scope types that are outside the public declaration +closure, not a mechanical destination for every `_` name. #### Function-local typedefs -Function-local JSDoc `@typedef` declarations are allowed everywhere. They are -useful for compile-time proofs that depend on values available only in lexical -scope and therefore cannot be moved to `private.ts`. +Function-local JSDoc `@typedef` declarations are allowed everywhere. They may +refer to lexical values that cannot be named from a sibling TypeScript file. For example: @@ -145,7 +98,7 @@ const proof = () => { } ``` -A callback-local proof is also valid: +Callback-local type proofs are also valid: ```js ({ kind }) => { @@ -154,20 +107,18 @@ A callback-local proof is also valid: } ``` -These typedefs stay inside the narrowest function scope that provides the values -they need. Private function-local typedef names keep the leading `_` convention. -Declaration validation must verify that they remain lexical and do not appear as +Private function-local typedefs keep the leading `_`. Declaration validation +must verify that function-local typedefs remain lexical and do not escape as exported aliases in generated `.d.ts` / `.d.mts` files. -#### Type metadata +#### `meta.f.mjs` -`meta.f.mjs` contains runtime constants that TypeScript type definitions or -file-scope type proofs refer to. The values do **not** need to be RTTI, and they -do not need to exist primarily for type-system purposes. A normal runtime -constant belongs in `meta.f.mjs` when its literal value or inferred type is part -of a TypeScript type definition/proof. +`meta.f.mjs` contains runtime constants whose literal/inferred types are +actually referenced by TypeScript type definitions or file-scope type proofs. +They do not need to be RTTI and do not need to exist primarily for type-system +purposes. -This includes RTTI definitions: +Examples include RTTI values: ```ts import type { type } from './meta.f.mjs' @@ -183,7 +134,7 @@ import type { statuses } from './meta.f.mjs' export type Status = typeof statuses[number] ``` -and runtime tables that are also used by normal implementation code: +and normal runtime tables whose type is asserted: ```js // meta.f.mjs @@ -204,21 +155,12 @@ type _KeywordsAreComplete = import { framingKeywords } from './meta.f.mjs' ``` -This is the intended solution for file-scope type proofs over module constants: -move the referenced constant to `meta.f.mjs`, keep its runtime consumers using a -normal JavaScript import, and move the file-scope private proof/type to -`private.ts` (or `types.ts` when it is required by a public declaration). The -constant does not become RTTI merely because it lives in `meta.f.mjs`; `meta` -means that its value participates in the type-level model. - -Do not move arbitrary runtime values to `meta.f.mjs` merely because their type -could theoretically be queried. The trigger is an actual TypeScript type -reference/proof (`typeof`, `Ts`, indexed access, etc.). +The trigger is an actual TypeScript type dependency (`typeof`, +`Ts`, indexed access, a type proof, etc.), not merely that a runtime +value *could* be queried. -Both `types.ts` and `private.ts` may depend on `meta.f.mjs` for -`Ts`, `typeof ...`, indexed access over literal values, and similar -type derivation. All imports in authored TypeScript type files use the named -type-only form: +Runtime code imports values from `meta.f.mjs` normally. Authored TypeScript type +modules use only named type-only imports: ```ts import type { PublicType } from './types.ts' @@ -228,214 +170,174 @@ import type { metadataValue } from './meta.f.mjs' Do not use runtime `import { ... }`, namespace imports, or side-effect imports in `types.ts` or `private.ts`. -`meta.f.mjs` is executable FunctionalScript source and is packaged like other -required `.f.mjs` modules. Node and Deno coverage filters must include it under -the same coverage expectations as `module.f.mjs`. - -#### Dependency rules - -- `module.f.mjs` and `proof.f.mjs` may use `types.ts` and `private.ts` through - JSDoc `@import`. -- `module.f.mjs` and other runtime modules may import runtime constants normally - from `meta.f.mjs`. -- `private.ts` may `import type { ... }` public types from `types.ts`. -- `types.ts` must not depend on `private.ts`. -- `_` helpers required to express public aliases remain in `types.ts` rather - than creating a `types.ts -> private.ts` edge. -- `types.ts` and `private.ts` may `import type { ... }` constants from - `meta.f.mjs` when those values participate in TypeScript type definitions or - proofs. -- all imports in `types.ts` and `private.ts` are named `import type { ... }` - imports and must not create runtime dependencies. -- a public declaration must never depend on the removable `private.ts` module. - -#### Breaking public API migration - -Moving a public file-scope JSDoc typedef from `module.f.mjs` or `proof.f.mjs` to -`types.ts` changes its published type import path. Moving a public runtime -constant from `module.f.mjs` to `meta.f.mjs` changes its published runtime import -path. Treat **both** relocations as intentional breaking API changes; do not -preserve the old entry points with compatibility typedefs, exports, or re-exports. - -For types: +`meta.f.mjs` is executable FunctionalScript source. Node and Deno coverage must +include it under the same expectations as `module.f.mjs`. -```text -./module.f.mjs -> ./types.ts -``` +#### Breaking migration; no compatibility re-exports + +Moving a public file-scope type from `module.f.mjs` / `proof.f.mjs` to +`types.ts` changes its public type import path. Moving a public runtime constant +from `module.f.mjs` to `meta.f.mjs` changes its runtime import path. -For runtime metadata: +Treat both as intentional breaking API changes: ```text -./module.f.mjs -> ./meta.f.mjs +public type: ./module.f.mjs -> ./types.ts +public metadata: ./module.f.mjs -> ./meta.f.mjs ``` -The migration must update every repository importer to the new path and record -the breaking change in the changelog. Keeping compatibility aliases or -re-exports in `module.f.mjs` would preserve exactly the mixed responsibilities -this convention is intended to remove. +Update every repository importer and the changelog. Do not preserve old entry +points with compatibility typedefs, exports, or re-exports. -### Declaration emission and packaging +#### Declaration emission and packaging -`private.ts` is source-only and remains in the normal TypeScript program so its -types and all `@import` users are checked. Consequently, the existing -`tsc --emitDeclarationOnly` pass will also generate `private.d.ts`; `exclude` -cannot suppress that output once another program input imports `private.ts`. +`private.ts` remains in the normal TypeScript program so its declarations and +all JSDoc `@import` users are checked. Therefore normal declaration emit may +produce an intermediate `private.d.ts`. -Do not require TypeScript to avoid generating that intermediate file. Make -private declaration cleanup the **final step of `prepack`**. `npm pack` runs -`prepack` itself, so an external emit/check/cleanup sequence followed by -`npm pack` would recreate the deleted declarations. +Do not try to exclude `private.ts` from the TypeScript program. Instead make +private-declaration cleanup the final `prepack` step: -The packaging lifecycle is: +1. emit declarations; +2. run the existing declaration round-trip type-check; +3. delete every generated `private.d.ts` as the final `prepack` command; +4. let `npm pack` select files after `prepack` completes; +5. inspect the actual tarball; +6. install the tarball in a clean TypeScript consumer and type-check it. -1. `prepack` runs normal declaration emission; -2. `prepack` runs the existing declaration round-trip type-check; -3. as the final `prepack` command, delete every generated `private.d.ts`; -4. `npm pack` selects package contents; -5. validate the actual packed artifact: - - it contains neither authored `private.ts` nor generated `private.d.ts`; - - every shipped `.d.ts` / `.d.mts` is scanned and must not reference a - directory's `private` type module; -6. install the tarball in the clean TypeScript consumer and type-check it. - -Conceptually, the current `prepack`: +Conceptually: ```text -tsc --noEmit false --emitDeclarationOnly && tsc +tsc --noEmit false --emitDeclarationOnly && tsc && ``` -becomes: +Validation of the packed artifact must prove both: + +- neither authored `private.ts` nor generated `private.d.ts` is shipped; +- no packed `.d.ts` / `.d.mts` references a directory's private type module, + regardless of the exact emitted suffix. + +References to packaged `meta.f.mjs` are allowed. + +#### Repository-policy reconciliation + +[`../fsc/README.md`](../fsc/README.md) currently documents the leading `_` as an +interim API contract for private JSDoc typedefs that TypeScript leaks into +emitted declarations. The upstream blocker is +[microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407), +and the wait-for-`@internal`/`stripInternal` strategy is tracked in +[`../../todo/blocked/jsdoc-typedef-strip-internal.md`](../../todo/blocked/jsdoc-typedef-strip-internal.md). + +That policy remains authoritative until this migration is implemented. When this +TODO lands, update `fjs/fsc/README.md` and delete or narrow the blocked TODO so +the repository has one private-type strategy. + +The migration also changes [`../AGENTS.md`](../AGENTS.md), which currently says +`types.ts` is the only authored TypeScript under `fjs/`. Update it so the allowed +authored TypeScript type-module roles are: ```text -tsc --noEmit false --emitDeclarationOnly && tsc && +types.ts # public declaration closure +private.ts # implementation-private file-scope types outside that closure ``` -The cleanup command should use repository-portable tooling. Tests should invoke -`npm pack` normally so they exercise the real lifecycle. - -The declaration scan should reject the private module rather than one particular -specifier spelling (`./private.ts`, `./private.d.ts`, or a future equivalent). -References to packaged `meta.f.mjs` are allowed. +Both remain type-only modules and use named `import type { ... }` imports. ### Tasks -- [ ] Document `private.ts` and `meta.f.mjs` beside the existing `types.ts`, - `module.*`, and `proof.*` conventions. -- [ ] Reconcile the implemented convention with the current private-JSDoc policy: - update `fjs/fsc/README.md` to replace the leaked-file-scope-typedef - workaround, and delete or narrow - `todo/blocked/jsdoc-typedef-strip-internal.md` so the repository has one - authoritative strategy. -- [ ] Update `fjs/AGENTS.md` so the authored-TypeScript policy allows exactly the - intended type-module roles: `types.ts` for public types/public-type helpers - and `private.ts` for implementation-private file-scope types. Preserve the - rule that all imports in those files are named `import type { ... }`. -- [ ] Prohibit file-scope JSDoc `@typedef` declarations in `module.f.mjs` and - `proof.f.mjs`; allow function-local `@typedef` declarations everywhere. -- [ ] Keep the leading `_` convention for every private type name, including - private helpers in `types.ts` and function-local private typedefs. +- [ ] Document `types.ts`, `private.ts`, and `meta.f.mjs` beside the existing + `module.*` / `proof.*` file conventions. +- [ ] Update `fjs/AGENTS.md` to allow `types.ts` and `private.ts` as the authored + TypeScript type-module roles and document the public-declaration-closure + rule. +- [ ] Update `fjs/fsc/README.md` and delete or narrow + `todo/blocked/jsdoc-typedef-strip-internal.md` so they no longer prescribe + a conflicting private-JSDoc strategy. +- [ ] Prohibit file-scope JSDoc `@typedef` in `module.f.mjs` and `proof.f.mjs`; + allow function-local `@typedef` everywhere. +- [ ] Keep the leading `_` convention for every private type name. - [ ] Move public file-scope named types from implementation/proof JSDoc into - `types.ts` as a breaking type-API migration; update every repository - importer to the new `types.ts` path and record the break in the changelog. -- [ ] Do not add compatibility typedefs or re-exports to preserve old - `module.f.mjs` type entry points. -- [ ] Keep `_` helpers required to express public declarations in `types.ts`; - do not create `types.ts -> private.ts` dependencies. -- [ ] Move other private file-scope named types out of `types.ts`, - `module.f.mjs`, and `proof.f.mjs` into each directory's `private.ts`. -- [ ] Keep lexical type-proof typedefs inside the functions whose local values - they inspect. -- [ ] Move runtime constants referenced by TypeScript type definitions/proofs - into `meta.f.mjs`, including RTTI definitions, non-RTTI literal constants, - and ordinary runtime tables whose literal/inferred types are asserted. -- [ ] Move file-scope private type proofs over those constants to `private.ts` - (or keep helpers in `types.ts` when required by a public declaration), and - use `import type { ... }` to reference the `meta.f.mjs` values. + `types.ts` as a breaking migration; update importers and changelog. +- [ ] Keep or inline every private `_` helper required transitively by any + shipped public declaration in `types.ts`, including helpers appearing in + exported runtime-value/function signatures. +- [ ] Move only other implementation-private file-scope types to `private.ts`; + do not create `types.ts -> private.ts` or public-declaration -> `private.ts` + dependencies. +- [ ] Keep lexical type-proof typedefs inside their functions. +- [ ] Move runtime constants actually referenced by TypeScript type + definitions/proofs into `meta.f.mjs`, including RTTI values, non-RTTI + literal constants, and runtime-used tables. +- [ ] Move file-scope private proofs over those constants to `private.ts` (or + `types.ts` when part of the public declaration closure) and use + `import type { ... }`. - [ ] Treat moves of public runtime constants to `meta.f.mjs` as breaking API - changes: update every repository runtime importer and the changelog; do not - leave compatibility exports or re-exports in `module.f.mjs`. + changes; update runtime importers and changelog, with no compatibility + re-exports. - [ ] Require every import in `types.ts` and `private.ts` to use named `import type { ... }`. -- [ ] Update Node coverage selection to include both `module.f.mjs` and - `meta.f.mjs` under the existing thresholds. -- [ ] Update Deno `cov` and `cov-html` filters to include both `module.f.mjs` and - `meta.f.mjs`. -- [ ] Keep `private.ts` in normal TypeScript checking without generating runtime - JavaScript for it. -- [ ] Make deletion of generated `private.d.ts` files the final `prepack` step. -- [ ] Exercise cleanup through normal `npm pack`. -- [ ] Inspect the packed artifact and reject any `private.ts` or `private.d.ts`. -- [ ] Scan every packed `.d.ts` / `.d.mts` and reject any dependency on a - directory's private type module. -- [ ] Add a fixture covering all three private-type cases: - - a `_` helper in `types.ts` required by an exported public alias; - - an implementation-private `_` type in `private.ts`; - - a function-local `_` typedef depending on a lexical value. - Verify the first remains self-contained in `types.d.ts`, the second's - intermediate `private.d.ts` is removed, and the third does not escape. -- [ ] Extend the fixture with `meta.f.mjs` containing an RTTI value, a non-RTTI - literal constant, and a runtime-used constant whose type is asserted from - `private.ts`; verify runtime imports, type-only imports, source checking, - packing, clean-consumer resolution, and Node/Deno coverage. -- [ ] Verify a clean TypeScript consumer can install the packed tarball and use - the public API without any private artifact present. +- [ ] Update Node and Deno coverage filters to include `meta.f.mjs`. +- [ ] Keep `private.ts` in normal TypeScript checking without runtime JS emit. +- [ ] Make deletion of generated `private.d.ts` the final `prepack` step. +- [ ] Inspect the `npm pack` artifact for private files and private declaration + dependencies. +- [ ] Add a fixture covering: + - a private helper required by a public type alias; + - a private helper required by an exported runtime value/function + declaration (the `_SortedArray`/`find` shape); + - an implementation-private type in `private.ts`; + - a function-local typedef depending on a lexical value; + - `meta.f.mjs` with RTTI, literal, and runtime-used constants. +- [ ] Verify source checking, declaration emit/cleanup, Node+Deno coverage, + packing, and clean-consumer type checking. ### Acceptance criteria -- The current `_` leak-tolerance policy is explicitly superseded when this - migration is implemented; `fjs/fsc/README.md` and the blocked `@internal` / - `stripInternal` TODO no longer prescribe a conflicting strategy. -- `fjs/AGENTS.md` no longer says `types.ts` is the only authored TypeScript; - it documents `types.ts` and `private.ts` as the allowed authored TypeScript - type-module roles, with their public/private responsibilities and named - `import type { ... }` import rule. - `module.f.mjs` and `proof.f.mjs` contain no file-scope JSDoc `@typedef`. -- Function-local JSDoc `@typedef` declarations are allowed everywhere; private - ones keep `_` and do not escape as exported declaration aliases. -- Public file-scope types live in `types.ts`. -- Moving a public type from `module.f.mjs` / `proof.f.mjs` to `types.ts` is an - intentional breaking API change: repository importers use the new path, the - changelog records the break, and no compatibility typedef/re-export preserves - the old type entry point. -- Moving a public runtime constant from `module.f.mjs` to `meta.f.mjs` is also an - intentional breaking API change: repository importers use the new path, the - changelog records the break, and no compatibility export/re-export preserves - the old runtime entry point. -- Private `_` helpers required to express public types also remain in `types.ts` - and are not source exports merely because they are declaration helpers. -- Other private file-scope types live in `private.ts` and keep `_`. -- `types.ts` never depends on `private.ts`. +- Function-local JSDoc `@typedef` is allowed everywhere; private names keep `_` + and do not escape as exported declaration aliases. +- `types.ts` is the public declaration closure: public types plus any private + helpers required transitively to express shipped declarations of public types + or exported runtime values/functions. +- `private.ts` contains only implementation-private file-scope types outside the + public declaration closure and is expected to be used sparingly where + `types.ts` already describes most of a module's type surface. +- `types.ts` and every packed public declaration are independent of + `private.ts`. - Every import in `types.ts` and `private.ts` uses named `import type { ... }`. -- Runtime constants referenced by TypeScript type definitions/proofs live in - `meta.f.mjs`, whether they are RTTI, literal metadata, or ordinary runtime - tables also consumed by implementation code. -- File-scope private proofs over such constants can live in `private.ts` without - exporting implementation locals from `module.f.mjs`. -- Node and Deno coverage include executable `meta.f.mjs` files. -- Declaration emission may create `private.d.ts`; the final `prepack` step - removes it before package contents are selected. -- The packed tarball contains neither `private.ts` nor `private.d.ts`. -- No packed declaration depends on a directory's private type module. -- Public declaration helpers retained in `types.ts` remain resolvable from the - shipped `types.d.ts` without any private artifact. -- Required references to packaged `meta.f.mjs` remain valid in the packed - artifact and clean consumer. +- Runtime constants referenced by TypeScript definitions/proofs live in + `meta.f.mjs`, whether RTTI or not; executable metadata is covered by Node and + Deno coverage. +- Moving public types to `types.ts` and public runtime metadata to `meta.f.mjs` + are breaking migrations: importers and changelog are updated and no + compatibility re-exports preserve old entry points. +- Declaration emit may create `private.d.ts`; final-`prepack` cleanup removes it + before package contents are selected. +- The packed tarball contains neither `private.ts` nor `private.d.ts`, and no + packed declaration depends on the private module. +- Public declaration helpers retained in `types.ts` remain self-contained and + resolvable from shipped declarations, including helpers used by exported + runtime-value/function signatures. +- `fjs/AGENTS.md` no longer says `types.ts` is the only authored TypeScript and + documents both `types.ts` and `private.ts` with the declaration-closure rule. +- `fjs/fsc/README.md` and the blocked `@internal`/`stripInternal` TODO no longer + prescribe a conflicting private-JSDoc strategy. - A clean TypeScript consumer type-checks successfully against the packed - tarball after all private artifacts have been removed. + tarball after private artifacts are removed. ### Related -- [`../fsc/README.md`](../fsc/README.md) — current `_` leak-tolerance policy that - this migration supersedes once implemented. -- [`../AGENTS.md`](../AGENTS.md) — current authored-TypeScript policy that must be - updated when `private.ts` becomes an allowed authored type module. +- [`../fsc/README.md`](../fsc/README.md) — current `_` leak-tolerance policy. +- [`../AGENTS.md`](../AGENTS.md) — authored-TypeScript policy to update. - [`../../todo/blocked/jsdoc-typedef-strip-internal.md`](../../todo/blocked/jsdoc-typedef-strip-internal.md) - — current wait-for-`@internal`/`stripInternal` strategy; delete or narrow when - this migration lands. + — current wait-for-`@internal`/`stripInternal` strategy. - [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) - — upstream JSDoc `@typedef` stripping limitation that motivated the current - workaround. -- [`detect-unexported-types-referenced-by-exported-types.md`](./detect-unexported-types-referenced-by-exported-types.md) — detect private type names that leak through exported types. -- [`document-file-type-naming-conventions.md`](./document-file-type-naming-conventions.md) — document the repository's source-file roles. -- [`../../todo/migrate-typescript-to-mjs.md`](../../todo/migrate-typescript-to-mjs.md) — current JavaScript/JSDoc implementation migration and `_` private-type convention. -- [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) — declaration emission and clean packed-package validation. + — upstream JSDoc typedef stripping limitation. +- [`detect-unexported-types-referenced-by-exported-types.md`](./detect-unexported-types-referenced-by-exported-types.md) + — related declaration-leak detection. +- [`document-file-type-naming-conventions.md`](./document-file-type-naming-conventions.md) + — repository source-file roles. +- [`../../todo/migrate-typescript-to-mjs.md`](../../todo/migrate-typescript-to-mjs.md) + — current JavaScript/JSDoc migration and `_` convention. +- [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) + — declaration emission and clean package validation. From eb21a3e88d12f9dd5a096a70c0bebe70580787b9 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:38:41 -0700 Subject: [PATCH 057/370] ok --- fjs/bnf/descent/module.f.mjs | 12 ++---------- fjs/bnf/descent/private.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 fjs/bnf/descent/private.ts diff --git a/fjs/bnf/descent/module.f.mjs b/fjs/bnf/descent/module.f.mjs index 70bd90998..871cdc2e5 100644 --- a/fjs/bnf/descent/module.f.mjs +++ b/fjs/bnf/descent/module.f.mjs @@ -25,6 +25,7 @@ * * @module * + * @import { _Failure } from './private.ts' * @import { TerminalRange } from '../types.ts' * @import { Rule as DataRule, RuleSet, Sequence } from '../data/types.ts' * @import { Rule as FRule } from '../types.ts' @@ -40,16 +41,7 @@ import { definedEntries } from '../../types/object/module.f.mjs' import { emptyTagMap, isRepeat, toData } from '../data/module.f.mjs' import { leafAt, mrFail, mrSuccess, physicalIdx, symbolAt } from '../matcher/module.f.mjs' -/** - * The furthest-failure record while matching, positioned by the complete - * {@link Cursor}. {@link DescentFailure} is its public, physically-positioned - * form. - * - * @typedef {{ - * readonly pos: Cursor - * readonly expected: readonly TerminalRange[] - * }} _Failure - */ + /** * The machine's own result: a {@link DescentMatchResult} positioned by the diff --git a/fjs/bnf/descent/private.ts b/fjs/bnf/descent/private.ts new file mode 100644 index 000000000..452fe8e6f --- /dev/null +++ b/fjs/bnf/descent/private.ts @@ -0,0 +1,12 @@ +import type { Cursor } from "../matcher/types.ts" +import type { TerminalRange } from "../types.ts" + +/** + * The furthest-failure record while matching, positioned by the complete + * {@link Cursor}. {@link DescentFailure} is its public, physically-positioned + * form. + */ +export type _Failure = { + readonly pos: Cursor + readonly expected: readonly TerminalRange[] +} From 5ffffd7316ad63a06fe238b85c813a92a089d82f Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:45:38 -0700 Subject: [PATCH 058/370] todo: apply typedef rule to all f.mjs files --- fjs/todo/separate-private-types.md | 61 +++++++++++++++++++----------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index c827be698..f7320c9ef 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -5,7 +5,8 @@ ### Problem -FunctionalScript currently mixes named types with implementation/proof source: +FunctionalScript currently mixes named types with runtime/proof source. Common +examples are: ```text module.f.mjs # implementation + file-scope JSDoc typedefs @@ -13,11 +14,13 @@ proof.f.mjs # proofs + file-scope JSDoc typedefs types.ts # public types + private helpers ``` -TypeScript declaration emit turns file-scope JSDoc `@typedef`s into exported -aliases, so implementation-private names leak into generated `.d.mts` files. -The existing leading-`_` convention marks those names private by contract, but -the declarations still contain noise and make the source/package boundary less -clear. +Other authored FunctionalScript companions, such as `testlib.f.mjs`, can contain +the same file-scope typedefs and are subject to the same declaration emit. +TypeScript turns file-scope JSDoc `@typedef`s in authored `.f.mjs` files into +exported aliases, so implementation-private names leak into generated `.d.mts` +files. The existing leading-`_` convention marks those names private by contract, +but the declarations still contain noise and make the source/package boundary +less clear. The goal is to give every file-scope named type a deliberate home while keeping public declarations self-contained. @@ -27,13 +30,18 @@ public declarations self-contained. Use this directory convention where needed: ```text -module.f.mjs # implementation; no file-scope @typedef -proof.f.mjs # proofs; no file-scope @typedef +module.f.mjs # implementation +proof.f.mjs # proofs meta.f.mjs # runtime constants referenced by TypeScript types/proofs types.ts # public declaration closure private.ts # other implementation-private file-scope types ``` +No authored `.f.mjs` file may declare a **file-scope** JSDoc `@typedef`, +regardless of basename or role. This includes `module.f.mjs`, `proof.f.mjs`, +`meta.f.mjs`, `testlib.f.mjs`, and other descriptive FunctionalScript +companions. Function-local typedefs remain allowed as described below. + Private type names continue to start with `_`. #### Public declaration closure @@ -86,8 +94,9 @@ closure, not a mechanical destination for every `_` name. #### Function-local typedefs -Function-local JSDoc `@typedef` declarations are allowed everywhere. They may -refer to lexical values that cannot be named from a sibling TypeScript file. +Function-local JSDoc `@typedef` declarations are allowed in any authored source +file. They may refer to lexical values that cannot be named from a sibling +TypeScript file. For example: @@ -175,9 +184,9 @@ include it under the same expectations as `module.f.mjs`. #### Breaking migration; no compatibility re-exports -Moving a public file-scope type from `module.f.mjs` / `proof.f.mjs` to -`types.ts` changes its public type import path. Moving a public runtime constant -from `module.f.mjs` to `meta.f.mjs` changes its runtime import path. +Moving a public file-scope type from any authored `.f.mjs` file to `types.ts` +changes its public type import path. Moving a public runtime constant from +`module.f.mjs` to `meta.f.mjs` changes its runtime import path. Treat both as intentional breaking API changes: @@ -241,22 +250,26 @@ types.ts # public declaration closure private.ts # implementation-private file-scope types outside that closure ``` -Both remain type-only modules and use named `import type { ... }` imports. +Both remain type-only modules and use named `import type { ... }` imports. The +same policy must also state that file-scope JSDoc `@typedef` is prohibited in +**all** authored `.f.mjs` files, not just `module.*` and `proof.*` entry points. ### Tasks - [ ] Document `types.ts`, `private.ts`, and `meta.f.mjs` beside the existing - `module.*` / `proof.*` file conventions. + FunctionalScript file conventions, including descriptive `.f.mjs` + companions. - [ ] Update `fjs/AGENTS.md` to allow `types.ts` and `private.ts` as the authored - TypeScript type-module roles and document the public-declaration-closure - rule. + TypeScript type-module roles, document the public-declaration-closure rule, + and prohibit file-scope `@typedef` in every authored `.f.mjs` file. - [ ] Update `fjs/fsc/README.md` and delete or narrow `todo/blocked/jsdoc-typedef-strip-internal.md` so they no longer prescribe a conflicting private-JSDoc strategy. -- [ ] Prohibit file-scope JSDoc `@typedef` in `module.f.mjs` and `proof.f.mjs`; - allow function-local `@typedef` everywhere. +- [ ] Prohibit file-scope JSDoc `@typedef` in every authored `.f.mjs`, including + `module.f.mjs`, `proof.f.mjs`, `meta.f.mjs`, `testlib.f.mjs`, and other + descriptive companions; allow function-local `@typedef` everywhere. - [ ] Keep the leading `_` convention for every private type name. -- [ ] Move public file-scope named types from implementation/proof JSDoc into +- [ ] Move public file-scope named types from authored `.f.mjs` JSDoc into `types.ts` as a breaking migration; update importers and changelog. - [ ] Keep or inline every private `_` helper required transitively by any shipped public declaration in `types.ts`, including helpers appearing in @@ -287,13 +300,16 @@ Both remain type-only modules and use named `import type { ... }` imports. declaration (the `_SortedArray`/`find` shape); - an implementation-private type in `private.ts`; - a function-local typedef depending on a lexical value; + - a descriptive companion such as `testlib.f.mjs` whose former file-scope + typedef is moved to the appropriate TypeScript file; - `meta.f.mjs` with RTTI, literal, and runtime-used constants. - [ ] Verify source checking, declaration emit/cleanup, Node+Deno coverage, packing, and clean-consumer type checking. ### Acceptance criteria -- `module.f.mjs` and `proof.f.mjs` contain no file-scope JSDoc `@typedef`. +- No authored `.f.mjs` file contains a file-scope JSDoc `@typedef`, regardless + of basename or role. - Function-local JSDoc `@typedef` is allowed everywhere; private names keep `_` and do not escape as exported declaration aliases. - `types.ts` is the public declaration closure: public types plus any private @@ -319,7 +335,8 @@ Both remain type-only modules and use named `import type { ... }` imports. resolvable from shipped declarations, including helpers used by exported runtime-value/function signatures. - `fjs/AGENTS.md` no longer says `types.ts` is the only authored TypeScript and - documents both `types.ts` and `private.ts` with the declaration-closure rule. + documents both `types.ts` and `private.ts`, the declaration-closure rule, and + the all-authored-`.f.mjs` file-scope typedef prohibition. - `fjs/fsc/README.md` and the blocked `@internal`/`stripInternal` TODO no longer prescribe a conflicting private-JSDoc strategy. - A clean TypeScript consumer type-checks successfully against the packed From 04019085955cc57a13a0c3ee2c8e957e134b9b10 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:48:47 -0700 Subject: [PATCH 059/370] todo: apply typedef rule to all mjs --- fjs/todo/separate-private-types.md | 97 +++++++++++++++--------------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index f7320c9ef..6599e618e 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -5,22 +5,22 @@ ### Problem -FunctionalScript currently mixes named types with runtime/proof source. Common -examples are: +FunctionalScript currently mixes named types with authored JavaScript source. +Common examples are: ```text -module.f.mjs # implementation + file-scope JSDoc typedefs +module.f.mjs # FunctionalScript implementation + file-scope JSDoc typedefs +module.mjs # host integration + file-scope JSDoc typedefs proof.f.mjs # proofs + file-scope JSDoc typedefs types.ts # public types + private helpers ``` -Other authored FunctionalScript companions, such as `testlib.f.mjs`, can contain -the same file-scope typedefs and are subject to the same declaration emit. -TypeScript turns file-scope JSDoc `@typedef`s in authored `.f.mjs` files into -exported aliases, so implementation-private names leak into generated `.d.mts` -files. The existing leading-`_` convention marks those names private by contract, -but the declarations still contain noise and make the source/package boundary -less clear. +Other authored JavaScript companions, such as `testlib.f.mjs`, can contain the +same file-scope typedefs and are subject to the same declaration emit. TypeScript +turns file-scope JSDoc `@typedef`s in authored `.mjs` files into exported aliases, +so implementation-private names leak into generated `.d.mts` files. The existing +leading-`_` convention marks those names private by contract, but the declarations +still contain noise and make the source/package boundary less clear. The goal is to give every file-scope named type a deliberate home while keeping public declarations self-contained. @@ -30,17 +30,20 @@ public declarations self-contained. Use this directory convention where needed: ```text -module.f.mjs # implementation -proof.f.mjs # proofs +module.f.mjs # FunctionalScript implementation +module.mjs # host integration, when needed +proof.f.mjs # FunctionalScript proofs +proof.mjs # host proofs, when needed meta.f.mjs # runtime constants referenced by TypeScript types/proofs types.ts # public declaration closure private.ts # other implementation-private file-scope types ``` -No authored `.f.mjs` file may declare a **file-scope** JSDoc `@typedef`, -regardless of basename or role. This includes `module.f.mjs`, `proof.f.mjs`, -`meta.f.mjs`, `testlib.f.mjs`, and other descriptive FunctionalScript -companions. Function-local typedefs remain allowed as described below. +No authored `.mjs` file may declare a **file-scope** JSDoc `@typedef`, regardless +of basename or whether the file is FunctionalScript. This includes +`module.f.mjs`, `module.mjs`, `proof.f.mjs`, `proof.mjs`, `meta.f.mjs`, +`testlib.f.mjs`, and other descriptive companions. Function-local typedefs remain +allowed as described below. Private type names continue to start with `_`. @@ -79,9 +82,9 @@ export const find: (cmp: Cmp) => ``` then `_SortedArray` is part of the public declaration closure and must remain in -`types.ts` (or be inlined into the public declaration). Moving it to -`private.ts` would make a shipped declaration depend on a declaration module -that packaging removes. +`types.ts` (or be inlined into the public declaration). Moving it to `private.ts` +would make a shipped declaration depend on a declaration module that packaging +removes. `types.ts` must never import `private.ts`. If moving a private helper to `private.ts` would create a `types.ts -> private.ts` edge or cause any generated @@ -122,10 +125,9 @@ exported aliases in generated `.d.ts` / `.d.mts` files. #### `meta.f.mjs` -`meta.f.mjs` contains runtime constants whose literal/inferred types are -actually referenced by TypeScript type definitions or file-scope type proofs. -They do not need to be RTTI and do not need to exist primarily for type-system -purposes. +`meta.f.mjs` contains runtime constants whose literal/inferred types are actually +referenced by TypeScript type definitions or file-scope type proofs. They do not +need to be RTTI and do not need to exist primarily for type-system purposes. Examples include RTTI values: @@ -164,9 +166,9 @@ type _KeywordsAreComplete = import { framingKeywords } from './meta.f.mjs' ``` -The trigger is an actual TypeScript type dependency (`typeof`, -`Ts`, indexed access, a type proof, etc.), not merely that a runtime -value *could* be queried. +The trigger is an actual TypeScript type dependency (`typeof`, `Ts`, +indexed access, a type proof, etc.), not merely that a runtime value *could* be +queried. Runtime code imports values from `meta.f.mjs` normally. Authored TypeScript type modules use only named type-only imports: @@ -184,14 +186,14 @@ include it under the same expectations as `module.f.mjs`. #### Breaking migration; no compatibility re-exports -Moving a public file-scope type from any authored `.f.mjs` file to `types.ts` +Moving a public file-scope type from any authored `.mjs` file to `types.ts` changes its public type import path. Moving a public runtime constant from `module.f.mjs` to `meta.f.mjs` changes its runtime import path. Treat both as intentional breaking API changes: ```text -public type: ./module.f.mjs -> ./types.ts +public type: ./.mjs -> ./types.ts public metadata: ./module.f.mjs -> ./meta.f.mjs ``` @@ -200,9 +202,9 @@ points with compatibility typedefs, exports, or re-exports. #### Declaration emission and packaging -`private.ts` remains in the normal TypeScript program so its declarations and -all JSDoc `@import` users are checked. Therefore normal declaration emit may -produce an intermediate `private.d.ts`. +`private.ts` remains in the normal TypeScript program so its declarations and all +JSDoc `@import` users are checked. Therefore normal declaration emit may produce +an intermediate `private.d.ts`. Do not try to exclude `private.ts` from the TypeScript program. Instead make private-declaration cleanup the final `prepack` step: @@ -231,8 +233,8 @@ References to packaged `meta.f.mjs` are allowed. #### Repository-policy reconciliation [`../fsc/README.md`](../fsc/README.md) currently documents the leading `_` as an -interim API contract for private JSDoc typedefs that TypeScript leaks into -emitted declarations. The upstream blocker is +interim API contract for private JSDoc typedefs that TypeScript leaks into emitted +declarations. The upstream blocker is [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407), and the wait-for-`@internal`/`stripInternal` strategy is tracked in [`../../todo/blocked/jsdoc-typedef-strip-internal.md`](../../todo/blocked/jsdoc-typedef-strip-internal.md). @@ -252,24 +254,24 @@ private.ts # implementation-private file-scope types outside that closure Both remain type-only modules and use named `import type { ... }` imports. The same policy must also state that file-scope JSDoc `@typedef` is prohibited in -**all** authored `.f.mjs` files, not just `module.*` and `proof.*` entry points. +**all authored `.mjs` files**, including non-FunctionalScript host JavaScript. ### Tasks - [ ] Document `types.ts`, `private.ts`, and `meta.f.mjs` beside the existing - FunctionalScript file conventions, including descriptive `.f.mjs` - companions. + JavaScript/FunctionalScript file conventions, including host `.mjs` and + descriptive companions. - [ ] Update `fjs/AGENTS.md` to allow `types.ts` and `private.ts` as the authored TypeScript type-module roles, document the public-declaration-closure rule, - and prohibit file-scope `@typedef` in every authored `.f.mjs` file. + and prohibit file-scope `@typedef` in every authored `.mjs` file. - [ ] Update `fjs/fsc/README.md` and delete or narrow `todo/blocked/jsdoc-typedef-strip-internal.md` so they no longer prescribe a conflicting private-JSDoc strategy. -- [ ] Prohibit file-scope JSDoc `@typedef` in every authored `.f.mjs`, including - `module.f.mjs`, `proof.f.mjs`, `meta.f.mjs`, `testlib.f.mjs`, and other - descriptive companions; allow function-local `@typedef` everywhere. +- [ ] Prohibit file-scope JSDoc `@typedef` in every authored `.mjs`, including + `.f.mjs`, host `module.mjs` / `proof.mjs`, and descriptive companions; + allow function-local `@typedef` everywhere. - [ ] Keep the leading `_` convention for every private type name. -- [ ] Move public file-scope named types from authored `.f.mjs` JSDoc into +- [ ] Move public file-scope named types from authored `.mjs` JSDoc into `types.ts` as a breaking migration; update importers and changelog. - [ ] Keep or inline every private `_` helper required transitively by any shipped public declaration in `types.ts`, including helpers appearing in @@ -300,7 +302,9 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in declaration (the `_SortedArray`/`find` shape); - an implementation-private type in `private.ts`; - a function-local typedef depending on a lexical value; - - a descriptive companion such as `testlib.f.mjs` whose former file-scope + - a FunctionalScript descriptive companion such as `testlib.f.mjs` whose + former file-scope typedef is moved to the appropriate TypeScript file; + - a non-FunctionalScript authored `.mjs` file whose former file-scope typedef is moved to the appropriate TypeScript file; - `meta.f.mjs` with RTTI, literal, and runtime-used constants. - [ ] Verify source checking, declaration emit/cleanup, Node+Deno coverage, @@ -308,8 +312,8 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in ### Acceptance criteria -- No authored `.f.mjs` file contains a file-scope JSDoc `@typedef`, regardless - of basename or role. +- No authored `.mjs` file contains a file-scope JSDoc `@typedef`, regardless of + basename, FunctionalScript marker, or role. - Function-local JSDoc `@typedef` is allowed everywhere; private names keep `_` and do not escape as exported declaration aliases. - `types.ts` is the public declaration closure: public types plus any private @@ -318,8 +322,7 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in - `private.ts` contains only implementation-private file-scope types outside the public declaration closure and is expected to be used sparingly where `types.ts` already describes most of a module's type surface. -- `types.ts` and every packed public declaration are independent of - `private.ts`. +- `types.ts` and every packed public declaration are independent of `private.ts`. - Every import in `types.ts` and `private.ts` uses named `import type { ... }`. - Runtime constants referenced by TypeScript definitions/proofs live in `meta.f.mjs`, whether RTTI or not; executable metadata is covered by Node and @@ -336,7 +339,7 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in runtime-value/function signatures. - `fjs/AGENTS.md` no longer says `types.ts` is the only authored TypeScript and documents both `types.ts` and `private.ts`, the declaration-closure rule, and - the all-authored-`.f.mjs` file-scope typedef prohibition. + the all-authored-`.mjs` file-scope typedef prohibition. - `fjs/fsc/README.md` and the blocked `@internal`/`stripInternal` TODO no longer prescribe a conflicting private-JSDoc strategy. - A clean TypeScript consumer type-checks successfully against the packed From 39c9c9d6b3fa0fe85886926677d050fbe362e08e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 16:50:50 +0000 Subject: [PATCH 060/370] emergent_testing: say what `TestResult.name` actually promises The new type's documentation over-promised. It described `name` as the identity `fmtImport` gives a leaf and `path` as the key chain within a module's `proof` export, while `moduleFailure` -- a source that will not link, or a `proof` export that cannot be enumerated -- produces a `TestResult` whose name is the raw source, whose path is empty and whose duration is whatever was being measured when it failed. Three fields meant less than the comment claimed, and the claim was written in this branch. Fixes the documentation rather than the shape. A module-level failure is counted deliberately: a report whose totals disagreed with its `results` would tell an automated consumer the suite was empty rather than broken. Turning it into a separate variant changes the public report and the totals story, which is a report-shape decision -- and the report shape now has three of those open at once (`path`'s redundancy, which root a report declares, and this), which the todo now says should be settled together. No behaviour change; the suite and `fjs t`'s output are unchanged. --- fjs/emergent_testing/browser.mjs | 8 ++++++ .../todo/share-browser-console-runner.md | 8 ++++++ fjs/emergent_testing/types.ts | 25 ++++++++++++++++--- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 3d307525f..a40f5445a 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -198,6 +198,14 @@ const runPromise = (value, fulfilled, rejected) => { * point: there is no leaf here, so there is no path and no `fmtImport` name to * build. What is known about it is its source, so its source is its name. * + * It is still a `TestResult`, and still counted, because a report whose totals + * disagreed with its `results` would tell an automated consumer that the suite + * was empty rather than that it was broken. The cost is that a consumer cannot + * assume every entry names a leaf — which is why {@link TestResult} says so. + * Whether these belong in a variant of their own is part of the report-shape + * decision `todo/share-browser-console-runner.md` tracks, and is deliberately + * not settled here. + * * @type {(source: string, duration: number, message: string, stack: string) => _BrowserTestResult} */ const moduleFailure = (source, duration, message, stack) => ({ diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 7e5d46c92..47e29f370 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -270,6 +270,14 @@ are shared. roots agree, and once the browser suite is a gate the question of which root a report declares is worth settling. It belongs to the report shape, with `path`. +- The report shape now has three open questions, and they want settling + together rather than one at a time: whether `path` survives now that `name` + exists; whether a report declares the root its module keys are relative to; + and whether a module-level failure — one that will not link, which the browser + reports as a `TestResult` named by its source so its totals cannot read as + "no tests" — belongs in a variant of its own instead. Each is small alone; + answering one without the others is how a report shape ends up carrying three + half-decisions. - The skeleton never asks which host it is running on. Anything host-specific is a part it calls; anything it cannot express through a part is a missing extension point, not a special case. diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index 37fd0ff9e..7cab86923 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -58,13 +58,32 @@ export type TestStatus = 'passed' | 'failed' * on — the shape of an extension point, not an omission. */ export type TestResult = { - /** The module key the leaf was discovered in, relative to the run's root. */ + /** The module key the outcome belongs to, relative to the run's root. */ readonly module: string - /** The key chain within that module's `proof` export, as `fmtPath` renders it. */ + /** + * The key chain within that module's `proof` export, as `fmtPath` renders + * it — empty when the outcome is not a leaf's. + */ readonly path: string - /** The identity `fmtImport` gives the leaf — the same string in every runner. */ + /** + * What ran. For a leaf this is `fmtImport(module, path)`, the identity every + * runner names it by. A runner may also report an outcome that has no leaf — + * the browser reports a module that will not link, so that a report saying + * "0 tests" cannot be confused with a suite that is merely broken — and + * names it by whatever it does know, which for a module is its source. + * + * So this is "what ran", not "which leaf ran". Whether a runner should + * report a non-leaf outcome through this type at all, or through a separate + * variant with its own fields, is open — see + * `todo/share-browser-console-runner.md`, with the rest of the report + * shape. + */ readonly name: string readonly status: TestStatus + /** + * How long it took. For a leaf, its own execution; for a non-leaf outcome, + * whatever the runner was measuring when it failed. + */ readonly duration: number } From 8622f5a1cfa24610f1dddda6e48e4ca0e6bd16cf Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 09:53:45 -0700 Subject: [PATCH 061/370] todo: clarify optional meta proof imports --- fjs/todo/separate-private-types.md | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 6599e618e..c602ddfc8 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -181,8 +181,17 @@ import type { metadataValue } from './meta.f.mjs' Do not use runtime `import { ... }`, namespace imports, or side-effect imports in `types.ts` or `private.ts`. -`meta.f.mjs` is executable FunctionalScript source. Node and Deno coverage must -include it under the same expectations as `module.f.mjs`. +`meta.f.mjs` is executable FunctionalScript source. Node and Deno coverage +filters should include it so metadata that is actually loaded is measured under +the same coverage expectations as `module.f.mjs`. Coverage inclusion does not +load modules by itself, and the convention does not require an otherwise-unused +`meta.f.mjs` to be imported solely to make it appear in a coverage report. + +When a directory has a `meta.f.mjs`, it is recommended for the corresponding +proof to import and exercise the metadata when that produces a meaningful runtime +check. This is a recommendation, not a requirement: a metadata module that is +used only through erased TypeScript `import type` references need not gain an +artificial runtime import just for coverage. #### Breaking migration; no compatibility re-exports @@ -291,7 +300,12 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in re-exports. - [ ] Require every import in `types.ts` and `private.ts` to use named `import type { ... }`. -- [ ] Update Node and Deno coverage filters to include `meta.f.mjs`. +- [ ] Update Node and Deno coverage filters to include `meta.f.mjs`; when a + metadata module is loaded, it must be subject to the same coverage + thresholds as other executable FunctionalScript source. +- [ ] Recommend importing and exercising `meta.f.mjs` from the corresponding + proof when that gives a meaningful runtime check; do not require artificial + proof imports solely to make otherwise-unused metadata appear in coverage. - [ ] Keep `private.ts` in normal TypeScript checking without runtime JS emit. - [ ] Make deletion of generated `private.d.ts` the final `prepack` step. - [ ] Inspect the `npm pack` artifact for private files and private declaration @@ -307,6 +321,10 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in - a non-FunctionalScript authored `.mjs` file whose former file-scope typedef is moved to the appropriate TypeScript file; - `meta.f.mjs` with RTTI, literal, and runtime-used constants. +- [ ] In the fixture, runtime-load at least one metadata path to prove that the + Node/Deno coverage filters include loaded `meta.f.mjs`; do not use the + fixture to impose a requirement that every metadata module/export in the + repository be runtime-loaded by a proof. - [ ] Verify source checking, declaration emit/cleanup, Node+Deno coverage, packing, and clean-consumer type checking. @@ -325,8 +343,9 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in - `types.ts` and every packed public declaration are independent of `private.ts`. - Every import in `types.ts` and `private.ts` uses named `import type { ... }`. - Runtime constants referenced by TypeScript definitions/proofs live in - `meta.f.mjs`, whether RTTI or not; executable metadata is covered by Node and - Deno coverage. + `meta.f.mjs`, whether RTTI or not. Node and Deno coverage filters include + `meta.f.mjs`, but proofs are not required to runtime-import metadata solely for + coverage; importing/exercising metadata from proofs is recommended when useful. - Moving public types to `types.ts` and public runtime metadata to `meta.f.mjs` are breaking migrations: importers and changelog are updated and no compatibility re-exports preserve old entry points. From 59c146826b15747aed6e56c62e1e013ad08a15f6 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 10:14:14 -0700 Subject: [PATCH 062/370] todo: treat retained JSDoc imports as comments --- fjs/todo/separate-private-types.md | 38 ++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index c602ddfc8..e9f536750 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -231,12 +231,27 @@ Conceptually: tsc --noEmit false --emitDeclarationOnly && tsc && ``` -Validation of the packed artifact must prove both: +Do **not** rewrite or post-process emitted declaration text. TypeScript may retain +source JSDoc comments such as: + +```js +/** @import { _Private } from './private.ts' */ +``` + +inside an emitted `.d.ts` / `.d.mts`. In a declaration file this is a comment, +not a TypeScript import or module dependency, so it may remain after +`private.d.ts` is deleted. + +Validation of the packed artifact must prove: - neither authored `private.ts` nor generated `private.d.ts` is shipped; -- no packed `.d.ts` / `.d.mts` references a directory's private type module, - regardless of the exact emitted suffix. +- no packed `.d.ts` / `.d.mts` has a **semantic TypeScript dependency** on a + directory's private type module. +A raw text search for `private.ts` / `@import` is therefore incorrect because it +would reject harmless retained comments. If a structural scan is used, it must +ignore comments and reject only actual declaration syntax that creates a module +dependency. The clean-consumer TypeScript check is the final semantic validation. References to packaged `meta.f.mjs` are allowed. #### Repository-policy reconciliation @@ -308,8 +323,10 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in proof imports solely to make otherwise-unused metadata appear in coverage. - [ ] Keep `private.ts` in normal TypeScript checking without runtime JS emit. - [ ] Make deletion of generated `private.d.ts` the final `prepack` step. -- [ ] Inspect the `npm pack` artifact for private files and private declaration - dependencies. +- [ ] Do not rewrite/post-process emitted declarations to remove retained JSDoc + `@import` comments; they are non-semantic in `.d.ts` / `.d.mts`. +- [ ] Inspect the `npm pack` artifact for private files and **semantic** private + declaration dependencies, ignoring retained comments. - [ ] Add a fixture covering: - a private helper required by a public type alias; - a private helper required by an exported runtime value/function @@ -321,6 +338,9 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in - a non-FunctionalScript authored `.mjs` file whose former file-scope typedef is moved to the appropriate TypeScript file; - `meta.f.mjs` with RTTI, literal, and runtime-used constants. +- [ ] Include a retained JSDoc `@import ... './private.ts'` comment in an emitted + declaration fixture and verify the clean consumer succeeds without + `private.ts`; this proves comments do not create package dependencies. - [ ] In the fixture, runtime-load at least one metadata path to prove that the Node/Deno coverage filters include loaded `meta.f.mjs`; do not use the fixture to impose a requirement that every metadata module/export in the @@ -351,8 +371,11 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in compatibility re-exports preserve old entry points. - Declaration emit may create `private.d.ts`; final-`prepack` cleanup removes it before package contents are selected. +- Emitted declarations are not text-postprocessed: retained JSDoc `@import` + comments may mention `private.ts` and are allowed because they do not create a + TypeScript module dependency. - The packed tarball contains neither `private.ts` nor `private.d.ts`, and no - packed declaration depends on the private module. + packed declaration has a semantic dependency on the private module. - Public declaration helpers retained in `types.ts` remain self-contained and resolvable from shipped declarations, including helpers used by exported runtime-value/function signatures. @@ -362,7 +385,8 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in - `fjs/fsc/README.md` and the blocked `@internal`/`stripInternal` TODO no longer prescribe a conflicting private-JSDoc strategy. - A clean TypeScript consumer type-checks successfully against the packed - tarball after private artifacts are removed. + tarball after private artifacts are removed, including when retained comments + mention the removed private source path. ### Related From 0a948de0bccb60f4d8f4993ade714611ad89c36b Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 10:20:42 -0700 Subject: [PATCH 063/370] todo: keep meta coverage policy minimal --- fjs/todo/separate-private-types.md | 37 +++++++++++------------------- 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index e9f536750..732375f9f 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -181,17 +181,12 @@ import type { metadataValue } from './meta.f.mjs' Do not use runtime `import { ... }`, namespace imports, or side-effect imports in `types.ts` or `private.ts`. -`meta.f.mjs` is executable FunctionalScript source. Node and Deno coverage -filters should include it so metadata that is actually loaded is measured under -the same coverage expectations as `module.f.mjs`. Coverage inclusion does not -load modules by itself, and the convention does not require an otherwise-unused -`meta.f.mjs` to be imported solely to make it appear in a coverage report. - -When a directory has a `meta.f.mjs`, it is recommended for the corresponding -proof to import and exercise the metadata when that produces a meaningful runtime -check. This is a recommendation, not a requirement: a metadata module that is -used only through erased TypeScript `import type` references need not gain an -artificial runtime import just for coverage. +`meta.f.mjs` is executable FunctionalScript source. Emergent testing already +loads every `*.f.mjs` during normal test discovery, including modules without a +`proof` export. Add `meta.f.mjs` to the Node and Deno coverage filters so the +existing coverage thresholds apply to it. How a particular metadata module +satisfies those thresholds is left to its developer; this convention does not +prescribe proof imports, calls, or other coverage-specific implementation choices. #### Breaking migration; no compatibility re-exports @@ -315,12 +310,8 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in re-exports. - [ ] Require every import in `types.ts` and `private.ts` to use named `import type { ... }`. -- [ ] Update Node and Deno coverage filters to include `meta.f.mjs`; when a - metadata module is loaded, it must be subject to the same coverage - thresholds as other executable FunctionalScript source. -- [ ] Recommend importing and exercising `meta.f.mjs` from the corresponding - proof when that gives a meaningful runtime check; do not require artificial - proof imports solely to make otherwise-unused metadata appear in coverage. +- [ ] Update Node and Deno coverage filters to include `meta.f.mjs`; emergent + testing already loads it, and the existing coverage thresholds apply. - [ ] Keep `private.ts` in normal TypeScript checking without runtime JS emit. - [ ] Make deletion of generated `private.d.ts` the final `prepack` step. - [ ] Do not rewrite/post-process emitted declarations to remove retained JSDoc @@ -341,10 +332,8 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in - [ ] Include a retained JSDoc `@import ... './private.ts'` comment in an emitted declaration fixture and verify the clean consumer succeeds without `private.ts`; this proves comments do not create package dependencies. -- [ ] In the fixture, runtime-load at least one metadata path to prove that the - Node/Deno coverage filters include loaded `meta.f.mjs`; do not use the - fixture to impose a requirement that every metadata module/export in the - repository be runtime-loaded by a proof. +- [ ] Verify the normal test runner loads fixture `meta.f.mjs` and Node/Deno + coverage includes it under the existing thresholds. - [ ] Verify source checking, declaration emit/cleanup, Node+Deno coverage, packing, and clean-consumer type checking. @@ -363,9 +352,9 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in - `types.ts` and every packed public declaration are independent of `private.ts`. - Every import in `types.ts` and `private.ts` uses named `import type { ... }`. - Runtime constants referenced by TypeScript definitions/proofs live in - `meta.f.mjs`, whether RTTI or not. Node and Deno coverage filters include - `meta.f.mjs`, but proofs are not required to runtime-import metadata solely for - coverage; importing/exercising metadata from proofs is recommended when useful. + `meta.f.mjs`, whether RTTI or not. Emergent testing loads `meta.f.mjs`, Node and + Deno coverage filters include it, and the existing coverage thresholds apply; + this convention does not prescribe how developers satisfy those thresholds. - Moving public types to `types.ts` and public runtime metadata to `meta.f.mjs` are breaking migrations: importers and changelog are updated and no compatibility re-exports preserve old entry points. From 01e4309176559b4c37ab41b8c2c808b23b95a8c1 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 10:24:56 -0700 Subject: [PATCH 064/370] todo: mark private metadata constants with underscore --- fjs/todo/separate-private-types.md | 34 ++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 732375f9f..62d0021a3 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -45,7 +45,7 @@ of basename or whether the file is FunctionalScript. This includes `testlib.f.mjs`, and other descriptive companions. Function-local typedefs remain allowed as described below. -Private type names continue to start with `_`. +Private type and runtime constant names continue to start with `_`. #### Public declaration closure @@ -149,23 +149,31 @@ and normal runtime tables whose type is asserted: ```js // meta.f.mjs -export const framingKeywords = +export const _framingKeywords = /** @type {const} */ (['import', 'const', 'export', 'default', 'from']) ``` ```ts // private.ts -import type { framingKeywords } from './meta.f.mjs' +import type { _framingKeywords } from './meta.f.mjs' type _KeywordsAreComplete = - Assert> + Assert> ``` ```js // module.f.mjs -import { framingKeywords } from './meta.f.mjs' +import { _framingKeywords } from './meta.f.mjs' ``` +Private constants in `meta.f.mjs` use the same leading-`_` API convention as +private types. They may need to be exported so sibling runtime or TypeScript +modules can name them, but that export is module linkage rather than public API: +consumers must not depend on `_`-prefixed constants directly. Renaming or removing +such a name is not a breaking change solely because it was exported. As with +private types, changes that alter an actual public runtime/type contract still +follow the normal breaking-change rules. + The trigger is an actual TypeScript type dependency (`typeof`, `Ts`, indexed access, a type proof, etc.), not merely that a runtime value *could* be queried. @@ -289,7 +297,8 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in - [ ] Prohibit file-scope JSDoc `@typedef` in every authored `.mjs`, including `.f.mjs`, host `module.mjs` / `proof.mjs`, and descriptive companions; allow function-local `@typedef` everywhere. -- [ ] Keep the leading `_` convention for every private type name. +- [ ] Keep the leading `_` convention for every private type and private runtime + metadata constant name. - [ ] Move public file-scope named types from authored `.mjs` JSDoc into `types.ts` as a breaking migration; update importers and changelog. - [ ] Keep or inline every private `_` helper required transitively by any @@ -301,7 +310,8 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in - [ ] Keep lexical type-proof typedefs inside their functions. - [ ] Move runtime constants actually referenced by TypeScript type definitions/proofs into `meta.f.mjs`, including RTTI values, non-RTTI - literal constants, and runtime-used tables. + literal constants, and runtime-used tables; prefix private ones with `_` + even when they must be exported for sibling-module access. - [ ] Move file-scope private proofs over those constants to `private.ts` (or `types.ts` when part of the public declaration closure) and use `import type { ... }`. @@ -328,7 +338,7 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in former file-scope typedef is moved to the appropriate TypeScript file; - a non-FunctionalScript authored `.mjs` file whose former file-scope typedef is moved to the appropriate TypeScript file; - - `meta.f.mjs` with RTTI, literal, and runtime-used constants. + - `meta.f.mjs` with RTTI, literal, runtime-used, and private `_` constants. - [ ] Include a retained JSDoc `@import ... './private.ts'` comment in an emitted declaration fixture and verify the clean consumer succeeds without `private.ts`; this proves comments do not create package dependencies. @@ -352,9 +362,11 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in - `types.ts` and every packed public declaration are independent of `private.ts`. - Every import in `types.ts` and `private.ts` uses named `import type { ... }`. - Runtime constants referenced by TypeScript definitions/proofs live in - `meta.f.mjs`, whether RTTI or not. Emergent testing loads `meta.f.mjs`, Node and - Deno coverage filters include it, and the existing coverage thresholds apply; - this convention does not prescribe how developers satisfy those thresholds. + `meta.f.mjs`, whether RTTI or not. Private constants use leading `_` even when + exported for sibling-module access; `_` marks them private by contract. + Emergent testing loads `meta.f.mjs`, Node and Deno coverage filters include it, + and the existing coverage thresholds apply; this convention does not prescribe + how developers satisfy those thresholds. - Moving public types to `types.ts` and public runtime metadata to `meta.f.mjs` are breaking migrations: importers and changelog are updated and no compatibility re-exports preserve old entry points. From 0d839d99bf6024c6ce01862ca363c965bc02fc87 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 10:30:39 -0700 Subject: [PATCH 065/370] todo: preserve type module dependency order --- fjs/todo/separate-private-types.md | 115 +++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 22 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 62d0021a3..c0db1e2c0 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -34,7 +34,7 @@ module.f.mjs # FunctionalScript implementation module.mjs # host integration, when needed proof.f.mjs # FunctionalScript proofs proof.mjs # host proofs, when needed -meta.f.mjs # runtime constants referenced by TypeScript types/proofs +meta.f.mjs # runtime metadata constants used to define/derive types types.ts # public declaration closure private.ts # other implementation-private file-scope types ``` @@ -47,6 +47,53 @@ allowed as described below. Private type and runtime constant names continue to start with `_`. +#### Dependency order + +Keep the source dependency order: + +```text +meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs +``` + +The arrow points from a dependency to a dependent: a file may depend on files to +its left, but moving a type proof must not introduce a reverse edge merely to +keep the proof near the declaration it checks. The order is a layering rule, not +a requirement that every file directly import its immediate neighbor. + +Place assertions in the earliest layer that can legitimately see everything they +assert without reversing this order: + +- invariants entirely inside the public type model belong in `types.ts`; +- implementation-private type invariants belong in `private.ts` when they do not + need downstream implementation values; +- assertions about `module.f.mjs` implementations, including function signatures, + belong downstream in `proof.f.mjs`, normally as function-local typedef proofs; +- host-specific implementation assertions belong downstream in `proof.mjs`. + +For example, `fjs/effects/types.ts` currently imports `step`, `catchStep`, +`resultStep`, `mapStep`, `resultMapStep`, and `unwrapStep` from `module.f.mjs` +only to assert their inferred signatures with `ReturnType`. Those +assertions verify the implementation layer, so the migration should move them to +`proof.f.mjs` rather than move the implementation functions into `meta.f.mjs`. +A representative proof can stay lexical: + +```js +signature: () => { + /** + * @typedef {Assert>, + * Effect<_AddOp | _MulOp, string, NotImplemented | string> + * >>} _StepSig + */ +} +``` + +Do not create a broad exception merely because a type proof uses `typeof` on a +runtime function. First check whether the proof can move to a downstream proof +file while preserving the dependency order. Narrow exceptions may still be +needed, but they should be justified by a concrete case after the cleaner +placement has been ruled out. + #### Public declaration closure `types.ts` is primarily the public type API, but it may contain private `_` @@ -61,7 +108,7 @@ public type -> types.ts private `_` helper used by any public declaration -> types.ts other file-scope private `_` type -> private.ts function-local typedef -> allowed in place -runtime constant referenced by TS types/proofs -> meta.f.mjs +runtime metadata constant used to define types -> meta.f.mjs ``` For example, a helper used by a public type stays in `types.ts`: @@ -125,11 +172,18 @@ exported aliases in generated `.d.ts` / `.d.mts` files. #### `meta.f.mjs` -`meta.f.mjs` contains runtime constants whose literal/inferred types are actually -referenced by TypeScript type definitions or file-scope type proofs. They do not -need to be RTTI and do not need to exist primarily for type-system purposes. +`meta.f.mjs` contains runtime metadata constants whose literal/inferred values are +part of the type-level model. They do not need to be RTTI, and normal runtime code +may also consume them. Typical cases are RTTI descriptors, `as const`-style data, +and lookup tables whose literal shape is used to define or derive TypeScript +types. -Examples include RTTI values: +`meta.f.mjs` is **not** a destination for ordinary implementation functions just +because a type proof inspects their signature with `typeof`, `ReturnType`, or +`Parameters`. Such functions stay in `module.f.mjs`; place their signature proofs +downstream according to the dependency-order rule above. + +Examples of metadata include RTTI values: ```ts import type { type } from './meta.f.mjs' @@ -174,10 +228,6 @@ such a name is not a breaking change solely because it was exported. As with private types, changes that alter an actual public runtime/type contract still follow the normal breaking-change rules. -The trigger is an actual TypeScript type dependency (`typeof`, `Ts`, -indexed access, a type proof, etc.), not merely that a runtime value *could* be -queried. - Runtime code imports values from `meta.f.mjs` normally. Authored TypeScript type modules use only named type-only imports: @@ -281,16 +331,20 @@ private.ts # implementation-private file-scope types outside that closure Both remain type-only modules and use named `import type { ... }` imports. The same policy must also state that file-scope JSDoc `@typedef` is prohibited in -**all authored `.mjs` files**, including non-FunctionalScript host JavaScript. +**all authored `.mjs` files**, including non-FunctionalScript host JavaScript, +and document the dependency order above. ### Tasks - [ ] Document `types.ts`, `private.ts`, and `meta.f.mjs` beside the existing JavaScript/FunctionalScript file conventions, including host `.mjs` and descriptive companions. +- [ ] Document and preserve the dependency order + `meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs`. - [ ] Update `fjs/AGENTS.md` to allow `types.ts` and `private.ts` as the authored - TypeScript type-module roles, document the public-declaration-closure rule, - and prohibit file-scope `@typedef` in every authored `.mjs` file. + TypeScript type-module roles, document the public-declaration-closure and + dependency-order rules, and prohibit file-scope `@typedef` in every + authored `.mjs` file. - [ ] Update `fjs/fsc/README.md` and delete or narrow `todo/blocked/jsdoc-typedef-strip-internal.md` so they no longer prescribe a conflicting private-JSDoc strategy. @@ -305,14 +359,20 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in shipped public declaration in `types.ts`, including helpers appearing in exported runtime-value/function signatures. - [ ] Move only other implementation-private file-scope types to `private.ts`; - do not create `types.ts -> private.ts` or public-declaration -> `private.ts` + do not create reverse dependency edges or public-declaration -> `private.ts` dependencies. +- [ ] Review type assertions that currently reverse the dependency order. Move + implementation-signature assertions downstream into proof files where + possible; specifically, move the `fjs/effects/types.ts` assertions over + `step` / `catchStep` / `resultStep` / `mapStep` / `resultMapStep` / + `unwrapStep` to function-local proofs in `fjs/effects/proof.f.mjs`. - [ ] Keep lexical type-proof typedefs inside their functions. -- [ ] Move runtime constants actually referenced by TypeScript type - definitions/proofs into `meta.f.mjs`, including RTTI values, non-RTTI - literal constants, and runtime-used tables; prefix private ones with `_` - even when they must be exported for sibling-module access. -- [ ] Move file-scope private proofs over those constants to `private.ts` (or +- [ ] Move runtime metadata constants used to define/derive TypeScript types into + `meta.f.mjs`, including RTTI values, non-RTTI literal constants, and + runtime-used tables; prefix private ones with `_` even when they must be + exported for sibling-module access. Do not move ordinary implementation + functions merely because a proof inspects their type. +- [ ] Move file-scope private proofs over metadata constants to `private.ts` (or `types.ts` when part of the public declaration closure) and use `import type { ... }`. - [ ] Treat moves of public runtime constants to `meta.f.mjs` as breaking API @@ -334,6 +394,8 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in declaration (the `_SortedArray`/`find` shape); - an implementation-private type in `private.ts`; - a function-local typedef depending on a lexical value; + - an implementation-function signature assertion placed downstream in a + proof rather than creating a `types.ts -> module.f.mjs` dependency; - a FunctionalScript descriptive companion such as `testlib.f.mjs` whose former file-scope typedef is moved to the appropriate TypeScript file; - a non-FunctionalScript authored `.mjs` file whose former file-scope @@ -349,6 +411,10 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in ### Acceptance criteria +- The dependency order + `meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs` + is preserved; type assertions do not create reverse edges merely for + convenience. - No authored `.mjs` file contains a file-scope JSDoc `@typedef`, regardless of basename, FunctionalScript marker, or role. - Function-local JSDoc `@typedef` is allowed everywhere; private names keep `_` @@ -360,8 +426,12 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in public declaration closure and is expected to be used sparingly where `types.ts` already describes most of a module's type surface. - `types.ts` and every packed public declaration are independent of `private.ts`. +- Assertions about ordinary implementation-function signatures live downstream + of `module.f.mjs` (normally in function-local `proof.f.mjs` typedefs) rather + than forcing those functions into `meta.f.mjs` or importing implementation + functions into `types.ts`. - Every import in `types.ts` and `private.ts` uses named `import type { ... }`. -- Runtime constants referenced by TypeScript definitions/proofs live in +- Runtime metadata constants used to define/derive TypeScript types live in `meta.f.mjs`, whether RTTI or not. Private constants use leading `_` even when exported for sibling-module access; `_` marks them private by contract. Emergent testing loads `meta.f.mjs`, Node and Deno coverage filters include it, @@ -381,8 +451,9 @@ same policy must also state that file-scope JSDoc `@typedef` is prohibited in resolvable from shipped declarations, including helpers used by exported runtime-value/function signatures. - `fjs/AGENTS.md` no longer says `types.ts` is the only authored TypeScript and - documents both `types.ts` and `private.ts`, the declaration-closure rule, and - the all-authored-`.mjs` file-scope typedef prohibition. + documents both `types.ts` and `private.ts`, the declaration-closure and + dependency-order rules, and the all-authored-`.mjs` file-scope typedef + prohibition. - `fjs/fsc/README.md` and the blocked `@internal`/`stripInternal` TODO no longer prescribe a conflicting private-JSDoc strategy. - A clean TypeScript consumer type-checks successfully against the packed From 114fd58b7b585daac3511b63d64ec4f9dce324cf Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 10:35:33 -0700 Subject: [PATCH 066/370] todo: make mjs typedef rule repository-wide --- fjs/todo/separate-private-types.md | 79 +++++++++++++++++------------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index c0db1e2c0..3ef52adc0 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -16,9 +16,11 @@ types.ts # public types + private helpers ``` Other authored JavaScript companions, such as `testlib.f.mjs`, can contain the -same file-scope typedefs and are subject to the same declaration emit. TypeScript -turns file-scope JSDoc `@typedef`s in authored `.mjs` files into exported aliases, -so implementation-private names leak into generated `.d.mts` files. The existing +same file-scope typedefs and are subject to the same declaration emit. The same +problem also exists outside `fjs/`; for example, `todo/proof.f.mjs` is an authored +`.mjs` file with a file-scope typedef. TypeScript turns file-scope JSDoc +`@typedef`s in authored `.mjs` files into exported aliases, so +implementation-private names leak into generated `.d.mts` files. The existing leading-`_` convention marks those names private by contract, but the declarations still contain noise and make the source/package boundary less clear. @@ -39,11 +41,12 @@ types.ts # public declaration closure private.ts # other implementation-private file-scope types ``` -No authored `.mjs` file may declare a **file-scope** JSDoc `@typedef`, regardless -of basename or whether the file is FunctionalScript. This includes -`module.f.mjs`, `module.mjs`, `proof.f.mjs`, `proof.mjs`, `meta.f.mjs`, -`testlib.f.mjs`, and other descriptive companions. Function-local typedefs remain -allowed as described below. +No authored `.mjs` file anywhere in the repository may declare a **file-scope** +JSDoc `@typedef`, regardless of directory, basename, or whether the file is +FunctionalScript. This includes `module.f.mjs`, `module.mjs`, `proof.f.mjs`, +`proof.mjs`, `meta.f.mjs`, `testlib.f.mjs`, root-level or `todo/` `.mjs` files, +and other descriptive companions. Function-local typedefs remain allowed as +described below. Private type and runtime constant names continue to start with `_`. @@ -75,16 +78,19 @@ For example, `fjs/effects/types.ts` currently imports `step`, `catchStep`, only to assert their inferred signatures with `ReturnType`. Those assertions verify the implementation layer, so the migration should move them to `proof.f.mjs` rather than move the implementation functions into `meta.f.mjs`. -A representative proof can stay lexical: +A representative group of compile-time-only checks can live inside one proof +function so every typedef remains lexical: ```js -signature: () => { +signatures: () => { /** * @typedef {Assert>, * Effect<_AddOp | _MulOp, string, NotImplemented | string> * >>} _StepSig */ + + /** @typedef {Assert>, Effect<...>>>} _CatchStepSig */ } ``` @@ -320,19 +326,20 @@ That policy remains authoritative until this migration is implemented. When this TODO lands, update `fjs/fsc/README.md` and delete or narrow the blocked TODO so the repository has one private-type strategy. -The migration also changes [`../AGENTS.md`](../AGENTS.md), which currently says -`types.ts` is the only authored TypeScript under `fjs/`. Update it so the allowed -authored TypeScript type-module roles are: +The `.mjs` typedef prohibition is repository-wide, so the implementation must +also update the root [`../../AGENTS.md`](../../AGENTS.md). The root policy should +state that no authored `.mjs` anywhere in the repository may contain a file-scope +JSDoc `@typedef`. `fjs/AGENTS.md` should then document the additional `fjs/`-specific +file roles and dependency order: ```text types.ts # public declaration closure private.ts # implementation-private file-scope types outside that closure ``` -Both remain type-only modules and use named `import type { ... }` imports. The -same policy must also state that file-scope JSDoc `@typedef` is prohibited in -**all authored `.mjs` files**, including non-FunctionalScript host JavaScript, -and document the dependency order above. +Both remain type-only modules and use named `import type { ... }` imports. Do not +leave a rule that only governs `fjs/` while root-level authored `.mjs` files such +as `todo/proof.f.mjs` remain outside the convention. ### Tasks @@ -341,16 +348,18 @@ and document the dependency order above. descriptive companions. - [ ] Document and preserve the dependency order `meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs`. +- [ ] Update root `AGENTS.md` to prohibit file-scope JSDoc `@typedef` in every + authored `.mjs` file anywhere in the repository. - [ ] Update `fjs/AGENTS.md` to allow `types.ts` and `private.ts` as the authored - TypeScript type-module roles, document the public-declaration-closure and - dependency-order rules, and prohibit file-scope `@typedef` in every - authored `.mjs` file. + TypeScript type-module roles and document the public-declaration-closure and + dependency-order rules for `fjs/`. - [ ] Update `fjs/fsc/README.md` and delete or narrow `todo/blocked/jsdoc-typedef-strip-internal.md` so they no longer prescribe a conflicting private-JSDoc strategy. -- [ ] Prohibit file-scope JSDoc `@typedef` in every authored `.mjs`, including - `.f.mjs`, host `module.mjs` / `proof.mjs`, and descriptive companions; +- [ ] Prohibit file-scope JSDoc `@typedef` in every authored `.mjs` repository-wide; allow function-local `@typedef` everywhere. +- [ ] Migrate existing authored `.mjs` violations outside `fjs/`, including + `todo/proof.f.mjs`, using the same placement rules. - [ ] Keep the leading `_` convention for every private type and private runtime metadata constant name. - [ ] Move public file-scope named types from authored `.mjs` JSDoc into @@ -365,7 +374,8 @@ and document the dependency order above. implementation-signature assertions downstream into proof files where possible; specifically, move the `fjs/effects/types.ts` assertions over `step` / `catchStep` / `resultStep` / `mapStep` / `resultMapStep` / - `unwrapStep` to function-local proofs in `fjs/effects/proof.f.mjs`. + `unwrapStep` into one or more proof functions with function-local typedefs + in `fjs/effects/proof.f.mjs`. - [ ] Keep lexical type-proof typedefs inside their functions. - [ ] Move runtime metadata constants used to define/derive TypeScript types into `meta.f.mjs`, including RTTI values, non-RTTI literal constants, and @@ -400,6 +410,7 @@ and document the dependency order above. former file-scope typedef is moved to the appropriate TypeScript file; - a non-FunctionalScript authored `.mjs` file whose former file-scope typedef is moved to the appropriate TypeScript file; + - a root/outside-`fjs/` authored `.mjs` case; - `meta.f.mjs` with RTTI, literal, runtime-used, and private `_` constants. - [ ] Include a retained JSDoc `@import ... './private.ts'` comment in an emitted declaration fixture and verify the clean consumer succeeds without @@ -415,8 +426,8 @@ and document the dependency order above. `meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs` is preserved; type assertions do not create reverse edges merely for convenience. -- No authored `.mjs` file contains a file-scope JSDoc `@typedef`, regardless of - basename, FunctionalScript marker, or role. +- No authored `.mjs` file anywhere in the repository contains a file-scope JSDoc + `@typedef`, regardless of directory, basename, FunctionalScript marker, or role. - Function-local JSDoc `@typedef` is allowed everywhere; private names keep `_` and do not escape as exported declaration aliases. - `types.ts` is the public declaration closure: public types plus any private @@ -427,9 +438,9 @@ and document the dependency order above. `types.ts` already describes most of a module's type surface. - `types.ts` and every packed public declaration are independent of `private.ts`. - Assertions about ordinary implementation-function signatures live downstream - of `module.f.mjs` (normally in function-local `proof.f.mjs` typedefs) rather - than forcing those functions into `meta.f.mjs` or importing implementation - functions into `types.ts`. + of `module.f.mjs` (normally inside proof functions with function-local typedefs + in `proof.f.mjs`) rather than forcing those functions into `meta.f.mjs` or + importing implementation functions into `types.ts`. - Every import in `types.ts` and `private.ts` uses named `import type { ... }`. - Runtime metadata constants used to define/derive TypeScript types live in `meta.f.mjs`, whether RTTI or not. Private constants use leading `_` even when @@ -450,10 +461,9 @@ and document the dependency order above. - Public declaration helpers retained in `types.ts` remain self-contained and resolvable from shipped declarations, including helpers used by exported runtime-value/function signatures. -- `fjs/AGENTS.md` no longer says `types.ts` is the only authored TypeScript and - documents both `types.ts` and `private.ts`, the declaration-closure and - dependency-order rules, and the all-authored-`.mjs` file-scope typedef - prohibition. +- Root `AGENTS.md` documents the repository-wide all-authored-`.mjs` file-scope + typedef prohibition, while `fjs/AGENTS.md` documents the `fjs/`-specific + `types.ts` / `private.ts` roles, declaration-closure rule, and dependency order. - `fjs/fsc/README.md` and the blocked `@internal`/`stripInternal` TODO no longer prescribe a conflicting private-JSDoc strategy. - A clean TypeScript consumer type-checks successfully against the packed @@ -463,7 +473,10 @@ and document the dependency order above. ### Related - [`../fsc/README.md`](../fsc/README.md) — current `_` leak-tolerance policy. -- [`../AGENTS.md`](../AGENTS.md) — authored-TypeScript policy to update. +- [`../../AGENTS.md`](../../AGENTS.md) — root repository policy to update with the + all-authored-`.mjs` rule. +- [`../AGENTS.md`](../AGENTS.md) — `fjs/`-specific authored-TypeScript and file-role + policy to update. - [`../../todo/blocked/jsdoc-typedef-strip-internal.md`](../../todo/blocked/jsdoc-typedef-strip-internal.md) — current wait-for-`@internal`/`stripInternal` strategy. - [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) From 94a1f0003ae7e505a4d588d70e417215609f339e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 17:40:32 +0000 Subject: [PATCH 067/370] emergent_testing: name the runners' asymmetry on non-leaf failures `TestResult.name` documents that a runner may report an outcome with no leaf behind it, which is true of the browser and reads as though it were true of both. It is not. Measured with two modules in the tree and only the first hostile: the browser records one failed result and runs the rest, while `fjs t` exits on an uncaught throw and the second module's passing proofs are never reported at all. A consumer reading only the type could reasonably infer a symmetry that does not exist, so the type now says which runner tolerates this and points at `todo/hostile-proof-values.md`, where closing the gap needs an operation the shared traversal does not have. That todo gains the measurement. Comment-only; no field changes, no behaviour change. --- fjs/emergent_testing/todo/hostile-proof-values.md | 7 ++++++- fjs/emergent_testing/types.ts | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/fjs/emergent_testing/todo/hostile-proof-values.md b/fjs/emergent_testing/todo/hostile-proof-values.md index 3bc46b646..b580bfbad 100644 --- a/fjs/emergent_testing/todo/hostile-proof-values.md +++ b/fjs/emergent_testing/todo/hostile-proof-values.md @@ -21,7 +21,12 @@ guard: the `collectTests` traversal enumerates a returned proof tree, and throwing accessor, a revoked `Proxy`, or a hostile `toString` panics through either, and there is no `try`/`catch` in FunctionalScript for the core to catch it with. `fjs t` ends with a stack trace and no summary; the browser runner -today loses one test and carries on. What is missing from the core is +today loses one test and carries on. Measured, with two modules in the tree and +only the first hostile: `fjs t` exits on an uncaught `hostile` and the second +module's passing proofs are never reported, while the browser records one failed +result and runs the rest. That asymmetry is now noted on `TestResult` in +`../types.ts`, because the type otherwise reads as though every runner tolerates +a non-leaf failure. What is missing from the core is *attribution*: naming the leaf whose value could not be read, and continuing with the rest. Whichever runner ends up on top of it, a page left in `running` or a process that exits with no summary is the outcome an automated controller diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index 7cab86923..ed5888d7d 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -77,6 +77,15 @@ export type TestResult = { * variant with its own fields, is open — see * `todo/share-browser-console-runner.md`, with the rest of the report * shape. + * + * **The runners are not symmetric here, and that is a known gap rather than + * a design.** Only the browser reports a non-leaf outcome at all: the same + * `proof` export that it records as one failed result makes `fjs t` panic, + * taking down the whole run — including the modules that would have passed, + * which are then never reported either. So a consumer must not read this + * field's tolerance as a promise that every runner keeps going. Closing the + * gap is `todo/hostile-proof-values.md`, which needs an operation the + * shared traversal does not have. */ readonly name: string readonly status: TestStatus From be38c0928a191ab0af5039e4ad46167a8bc0a54f Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 10:45:07 -0700 Subject: [PATCH 068/370] todo: clarify recursive metadata placement --- fjs/todo/separate-private-types.md | 98 ++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 26 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 3ef52adc0..a85ea4ead 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -63,6 +63,13 @@ its left, but moving a type proof must not introduce a reverse edge merely to keep the proof near the declaration it checks. The order is a layering rule, not a requirement that every file directly import its immediate neighbor. +The file-placement conventions below are defaults, not a mechanical classifier. +Analyze concrete cases and prefer the simplest organization that preserves this +dependency order. In particular, do not force a recursive RTTI constant into +`meta.f.mjs` when expressing its recursion requires a named annotation from +`types.ts`; keeping that constant downstream can be cleaner than creating a +`meta.f.mjs -> types.ts` reverse edge. + Place assertions in the earliest layer that can legitimately see everything they assert without reversing this order: @@ -94,11 +101,24 @@ signatures: () => { } ``` -Do not create a broad exception merely because a type proof uses `typeof` on a -runtime function. First check whether the proof can move to a downstream proof -file while preserving the dependency order. Narrow exceptions may still be -needed, but they should be justified by a concrete case after the cleaner -placement has been ruled out. +Recursive metadata needs the same case-by-case treatment. Two current examples +illustrate the intended approach: + +- `fjs/media/revision`: `LockMap` / `LockSchema` stay in `types.ts`; the recursive + `lock` RTTI constant may stay in `module.f.mjs` because its initializer needs + the named `LockSchema` annotation from `types.ts`; the `Assert>` + consistency checks that currently make `types.ts` import `module.f.mjs` move + downstream into a function in `proof.f.mjs`. +- `fjs/edag`: recursive RTTI such as `exp` may stay in `module.f.mjs` when its + explicit annotation depends on the public EDAG types; file-scope consistency + assertions such as `Assert` move into one or more proof functions so + the RTTI/type relationship is still pinned without leaking typedefs or + reversing the dependency order. + +These examples do not establish a special rule for every recursive type. They +show the process: inspect the cycle, preserve the dependency direction, and move +verification downstream when that produces a clearer structure. Narrow +exceptions may still be needed after the concrete case has been analyzed. #### Public declaration closure @@ -107,14 +127,14 @@ helpers when they are required to express any shipped public declaration. "Public declaration" includes both exported type aliases and declarations of exported runtime values/functions. -The placement rule is: +The default placement guide is: ```text public type -> types.ts private `_` helper used by any public declaration -> types.ts other file-scope private `_` type -> private.ts function-local typedef -> allowed in place -runtime metadata constant used to define types -> meta.f.mjs +runtime metadata constant used to define types -> meta.f.mjs, when layering permits ``` For example, a helper used by a public type stays in `types.ts`: @@ -178,11 +198,19 @@ exported aliases in generated `.d.ts` / `.d.mts` files. #### `meta.f.mjs` -`meta.f.mjs` contains runtime metadata constants whose literal/inferred values are -part of the type-level model. They do not need to be RTTI, and normal runtime code -may also consume them. Typical cases are RTTI descriptors, `as const`-style data, -and lookup tables whose literal shape is used to define or derive TypeScript -types. +`meta.f.mjs` is the preferred home for runtime metadata constants whose +literal/inferred values are part of the type-level model when they can live there +without reversing the dependency order. They do not need to be RTTI, and normal +runtime code may also consume them. Typical cases are RTTI descriptors, +`as const`-style data, and lookup tables whose literal shape is used to define or +derive TypeScript types. + +This is a convention, not an absolute rule. A recursive metadata constant that +needs a named annotation from `types.ts` may remain in `module.f.mjs` rather than +creating a reverse dependency. In such a case, move consistency assertions to a +downstream proof function when that removes the reverse edge or file-scope typedef +leak. The `media/revision` and `edag` examples above are the current models for +this analysis. `meta.f.mjs` is **not** a destination for ordinary implementation functions just because a type proof inspects their signature with `typeof`, `ReturnType`, or @@ -348,6 +376,9 @@ as `todo/proof.f.mjs` remain outside the convention. descriptive companions. - [ ] Document and preserve the dependency order `meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs`. +- [ ] Treat the placement table as a default design guide, not a mechanical rule; + analyze recursive or otherwise constrained metadata case by case before + introducing exceptions or reverse dependencies. - [ ] Update root `AGENTS.md` to prohibit file-scope JSDoc `@typedef` in every authored `.mjs` file anywhere in the repository. - [ ] Update `fjs/AGENTS.md` to allow `types.ts` and `private.ts` as the authored @@ -376,15 +407,24 @@ as `todo/proof.f.mjs` remain outside the convention. `step` / `catchStep` / `resultStep` / `mapStep` / `resultMapStep` / `unwrapStep` into one or more proof functions with function-local typedefs in `fjs/effects/proof.f.mjs`. +- [ ] Review recursive metadata cases individually. For `fjs/media/revision`, + keep the recursive `lock` RTTI in `module.f.mjs` if its `LockSchema` + annotation requires `types.ts`, and move the `LockMap` / `LockField` + consistency asserts into a proof function. Apply the same analysis to + recursive EDAG RTTI such as `exp`: keep it in `module.f.mjs` when needed to + preserve layering and move file-scope consistency asserts into proof + functions. - [ ] Keep lexical type-proof typedefs inside their functions. -- [ ] Move runtime metadata constants used to define/derive TypeScript types into - `meta.f.mjs`, including RTTI values, non-RTTI literal constants, and - runtime-used tables; prefix private ones with `_` even when they must be - exported for sibling-module access. Do not move ordinary implementation - functions merely because a proof inspects their type. -- [ ] Move file-scope private proofs over metadata constants to `private.ts` (or - `types.ts` when part of the public declaration closure) and use - `import type { ... }`. +- [ ] Prefer `meta.f.mjs` for runtime metadata constants used to define/derive + TypeScript types when that placement preserves the dependency order, + including RTTI values, non-RTTI literal constants, and runtime-used tables; + prefix private ones with `_` even when they must be exported for + sibling-module access. Do not move ordinary implementation functions, or + recursively annotated metadata that would reverse the dependency order, + merely because a proof inspects their type. +- [ ] Move file-scope private proofs over metadata constants to `private.ts`, + `types.ts`, or a downstream proof function according to the concrete + dependency graph; do not force one placement mechanically. - [ ] Treat moves of public runtime constants to `meta.f.mjs` as breaking API changes; update runtime importers and changelog, with no compatibility re-exports. @@ -406,6 +446,8 @@ as `todo/proof.f.mjs` remain outside the convention. - a function-local typedef depending on a lexical value; - an implementation-function signature assertion placed downstream in a proof rather than creating a `types.ts -> module.f.mjs` dependency; + - a recursive metadata case whose named type annotation keeps it in + `module.f.mjs` while its consistency assert moves into a proof function; - a FunctionalScript descriptive companion such as `testlib.f.mjs` whose former file-scope typedef is moved to the appropriate TypeScript file; - a non-FunctionalScript authored `.mjs` file whose former file-scope @@ -426,6 +468,10 @@ as `todo/proof.f.mjs` remain outside the convention. `meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs` is preserved; type assertions do not create reverse edges merely for convenience. +- File placement follows the dependency order and concrete design needs rather + than a blanket syntactic rule; recursive metadata may remain in `module.f.mjs` + when moving it to `meta.f.mjs` would require a `types.ts` dependency, with + consistency assertions moved downstream when appropriate. - No authored `.mjs` file anywhere in the repository contains a file-scope JSDoc `@typedef`, regardless of directory, basename, FunctionalScript marker, or role. - Function-local JSDoc `@typedef` is allowed everywhere; private names keep `_` @@ -442,12 +488,12 @@ as `todo/proof.f.mjs` remain outside the convention. in `proof.f.mjs`) rather than forcing those functions into `meta.f.mjs` or importing implementation functions into `types.ts`. - Every import in `types.ts` and `private.ts` uses named `import type { ... }`. -- Runtime metadata constants used to define/derive TypeScript types live in - `meta.f.mjs`, whether RTTI or not. Private constants use leading `_` even when - exported for sibling-module access; `_` marks them private by contract. - Emergent testing loads `meta.f.mjs`, Node and Deno coverage filters include it, - and the existing coverage thresholds apply; this convention does not prescribe - how developers satisfy those thresholds. +- Runtime metadata constants used to define/derive TypeScript types normally live + in `meta.f.mjs` when the dependency order permits it. Private constants use + leading `_` even when exported for sibling-module access; `_` marks them private + by contract. Emergent testing loads `meta.f.mjs`, Node and Deno coverage filters + include it, and the existing coverage thresholds apply; this convention does + not prescribe how developers satisfy those thresholds. - Moving public types to `types.ts` and public runtime metadata to `meta.f.mjs` are breaking migrations: importers and changelog are updated and no compatibility re-exports preserve old entry points. From 1e69c5ac564b57f0b2bd32b52ed736329d746e08 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 10:49:50 -0700 Subject: [PATCH 069/370] todo: make private/meta files optional tools --- fjs/todo/separate-private-types.md | 264 +++++++++++++++-------------- 1 file changed, 137 insertions(+), 127 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index a85ea4ead..0f5285f21 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -1,4 +1,4 @@ -## Separate private types into `private.ts` +## Keep private types out of public declarations **Priority:** P2 **Status:** open @@ -24,23 +24,34 @@ implementation-private names leak into generated `.d.mts` files. The existing leading-`_` convention marks those names private by contract, but the declarations still contain noise and make the source/package boundary less clear. -The goal is to give every file-scope named type a deliberate home while keeping -public declarations self-contained. +The requirement is to keep the public declaration/API surface clean and +self-contained. `private.ts` and `meta.f.mjs` are **optional tools** for reaching +that result; they are not file roles that every module must introduce. ### Proposal -Use this directory convention where needed: +Use these file roles when they improve the concrete design: ```text module.f.mjs # FunctionalScript implementation module.mjs # host integration, when needed proof.f.mjs # FunctionalScript proofs proof.mjs # host proofs, when needed -meta.f.mjs # runtime metadata constants used to define/derive types +meta.f.mjs # optional runtime metadata/data extracted to support type structure types.ts # public declaration closure -private.ts # other implementation-private file-scope types +private.ts # optional implementation-private file-scope TypeScript ``` +The design target is the public boundary, not the presence of particular files: + +- `types.ts` describes the public declaration closure; +- use `private.ts` when moving implementation-private file-scope types out of the + public declaration surface makes the structure cleaner; +- use `meta.f.mjs` when extracting runtime metadata/data lets TypeScript types or + proofs depend on it without reversing the dependency order; +- do not create either file mechanically when a simpler organization already + keeps the public surface clean. + No authored `.mjs` file anywhere in the repository may declare a **file-scope** JSDoc `@typedef`, regardless of directory, basename, or whether the file is FunctionalScript. This includes `module.f.mjs`, `module.mjs`, `proof.f.mjs`, @@ -52,7 +63,7 @@ Private type and runtime constant names continue to start with `_`. #### Dependency order -Keep the source dependency order: +When these roles are present, keep the source dependency order: ```text meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs @@ -61,21 +72,21 @@ meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mj The arrow points from a dependency to a dependent: a file may depend on files to its left, but moving a type proof must not introduce a reverse edge merely to keep the proof near the declaration it checks. The order is a layering rule, not -a requirement that every file directly import its immediate neighbor. +a requirement that every file or edge exists. The file-placement conventions below are defaults, not a mechanical classifier. Analyze concrete cases and prefer the simplest organization that preserves this -dependency order. In particular, do not force a recursive RTTI constant into -`meta.f.mjs` when expressing its recursion requires a named annotation from -`types.ts`; keeping that constant downstream can be cleaner than creating a -`meta.f.mjs -> types.ts` reverse edge. +dependency order and the clean public boundary. In particular, do not force a +recursive RTTI constant into `meta.f.mjs` when expressing its recursion requires +a named annotation from `types.ts`; keeping that constant downstream can be +cleaner than creating a `meta.f.mjs -> types.ts` reverse edge. Place assertions in the earliest layer that can legitimately see everything they assert without reversing this order: - invariants entirely inside the public type model belong in `types.ts`; -- implementation-private type invariants belong in `private.ts` when they do not - need downstream implementation values; +- implementation-private type invariants may use `private.ts` when that is the + cleanest place and they do not need downstream implementation values; - assertions about `module.f.mjs` implementations, including function signatures, belong downstream in `proof.f.mjs`, normally as function-local typedef proofs; - host-specific implementation assertions belong downstream in `proof.mjs`. @@ -116,9 +127,10 @@ illustrate the intended approach: reversing the dependency order. These examples do not establish a special rule for every recursive type. They -show the process: inspect the cycle, preserve the dependency direction, and move -verification downstream when that produces a clearer structure. Narrow -exceptions may still be needed after the concrete case has been analyzed. +show the process: inspect the cycle, preserve the dependency direction, move +verification downstream when useful, and introduce `private.ts` / `meta.f.mjs` +only when they simplify the result. Narrow exceptions may still be needed after +the concrete case has been analyzed. #### Public declaration closure @@ -132,9 +144,9 @@ The default placement guide is: ```text public type -> types.ts private `_` helper used by any public declaration -> types.ts -other file-scope private `_` type -> private.ts +other file-scope private `_` type -> private.ts, when useful function-local typedef -> allowed in place -runtime metadata constant used to define types -> meta.f.mjs, when layering permits +runtime metadata/data extracted for type structure -> meta.f.mjs, when useful ``` For example, a helper used by a public type stays in `types.ts`: @@ -155,18 +167,19 @@ export const find: (cmp: Cmp) => ``` then `_SortedArray` is part of the public declaration closure and must remain in -`types.ts` (or be inlined into the public declaration). Moving it to `private.ts` -would make a shipped declaration depend on a declaration module that packaging -removes. +`types.ts` (or be inlined into the public declaration). Moving it to +`private.ts` would make a shipped declaration depend on a declaration module +that packaging removes. `types.ts` must never import `private.ts`. If moving a private helper to `private.ts` would create a `types.ts -> private.ts` edge or cause any generated public declaration to reference `private.ts`, keep or inline that helper in `types.ts` instead. -This should keep `private.ts` uncommon in `types.ts`-heavy modules: it is for +`private.ts` therefore should be uncommon. It is a tool for separating implementation-private file-scope types that are outside the public declaration -closure, not a mechanical destination for every `_` name. +closure; it is not a required companion and not a mechanical destination for +every `_` name. #### Function-local typedefs @@ -198,26 +211,20 @@ exported aliases in generated `.d.ts` / `.d.mts` files. #### `meta.f.mjs` -`meta.f.mjs` is the preferred home for runtime metadata constants whose -literal/inferred values are part of the type-level model when they can live there -without reversing the dependency order. They do not need to be RTTI, and normal -runtime code may also consume them. Typical cases are RTTI descriptors, +`meta.f.mjs` is an optional tool for extracting runtime metadata/data when doing +so improves the type/declaration boundary. Typical cases are RTTI descriptors, `as const`-style data, and lookup tables whose literal shape is used to define or derive TypeScript types. -This is a convention, not an absolute rule. A recursive metadata constant that -needs a named annotation from `types.ts` may remain in `module.f.mjs` rather than -creating a reverse dependency. In such a case, move consistency assertions to a -downstream proof function when that removes the reverse edge or file-scope typedef -leak. The `media/revision` and `edag` examples above are the current models for -this analysis. - -`meta.f.mjs` is **not** a destination for ordinary implementation functions just -because a type proof inspects their signature with `typeof`, `ReturnType`, or -`Parameters`. Such functions stay in `module.f.mjs`; place their signature proofs -downstream according to the dependency-order rule above. +Do not create `meta.f.mjs` merely because a runtime value participates in a type +proof. A recursive metadata constant that needs a named annotation from +`types.ts` may remain in `module.f.mjs`; ordinary implementation functions stay +in `module.f.mjs` even when a proof inspects their signature with `typeof`, +`ReturnType`, or `Parameters`. Move verification downstream when that is the +cleaner solution. The `media/revision`, `edag`, and `effects` examples above are +the current models for this analysis. -Examples of metadata include RTTI values: +When `meta.f.mjs` is useful, examples include RTTI values: ```ts import type { type } from './meta.f.mjs' @@ -273,12 +280,13 @@ import type { metadataValue } from './meta.f.mjs' Do not use runtime `import { ... }`, namespace imports, or side-effect imports in `types.ts` or `private.ts`. -`meta.f.mjs` is executable FunctionalScript source. Emergent testing already -loads every `*.f.mjs` during normal test discovery, including modules without a -`proof` export. Add `meta.f.mjs` to the Node and Deno coverage filters so the -existing coverage thresholds apply to it. How a particular metadata module -satisfies those thresholds is left to its developer; this convention does not -prescribe proof imports, calls, or other coverage-specific implementation choices. +When present, `meta.f.mjs` is executable FunctionalScript source. Emergent +testing already loads every `*.f.mjs` during normal test discovery, including +modules without a `proof` export. Add `meta.f.mjs` to the Node and Deno coverage +filters so the existing coverage thresholds apply to it. How a particular +metadata module satisfies those thresholds is left to its developer; this +convention does not prescribe proof imports, calls, or other coverage-specific +implementation choices. #### Breaking migration; no compatibility re-exports @@ -286,7 +294,7 @@ Moving a public file-scope type from any authored `.mjs` file to `types.ts` changes its public type import path. Moving a public runtime constant from `module.f.mjs` to `meta.f.mjs` changes its runtime import path. -Treat both as intentional breaking API changes: +Treat either move as an intentional breaking API change when it occurs: ```text public type: ./.mjs -> ./types.ts @@ -298,16 +306,16 @@ points with compatibility typedefs, exports, or re-exports. #### Declaration emission and packaging -`private.ts` remains in the normal TypeScript program so its declarations and all -JSDoc `@import` users are checked. Therefore normal declaration emit may produce -an intermediate `private.d.ts`. +If `private.ts` is used, it remains in the normal TypeScript program so its +declarations and all JSDoc `@import` users are checked. Normal declaration emit +may therefore produce an intermediate `private.d.ts`. -Do not try to exclude `private.ts` from the TypeScript program. Instead make -private-declaration cleanup the final `prepack` step: +Do not try to exclude `private.ts` from the TypeScript program. Instead, when +such files exist, make private-declaration cleanup the final `prepack` step: 1. emit declarations; 2. run the existing declaration round-trip type-check; -3. delete every generated `private.d.ts` as the final `prepack` command; +3. delete generated `private.d.ts` files as the final `prepack` command; 4. let `npm pack` select files after `prepack` completes; 5. inspect the actual tarball; 6. install the tarball in a clean TypeScript consumer and type-check it. @@ -331,9 +339,10 @@ not a TypeScript import or module dependency, so it may remain after Validation of the packed artifact must prove: -- neither authored `private.ts` nor generated `private.d.ts` is shipped; +- authored `private.ts` and generated `private.d.ts` are not shipped when those + files are used during source checking; - no packed `.d.ts` / `.d.mts` has a **semantic TypeScript dependency** on a - directory's private type module. + private type module that is not shipped. A raw text search for `private.ts` / `@import` is therefore incorrect because it would reject harmless retained comments. If a structural scan is used, it must @@ -358,39 +367,36 @@ The `.mjs` typedef prohibition is repository-wide, so the implementation must also update the root [`../../AGENTS.md`](../../AGENTS.md). The root policy should state that no authored `.mjs` anywhere in the repository may contain a file-scope JSDoc `@typedef`. `fjs/AGENTS.md` should then document the additional `fjs/`-specific -file roles and dependency order: - -```text -types.ts # public declaration closure -private.ts # implementation-private file-scope types outside that closure -``` +file roles and dependency order, making clear that `private.ts` and `meta.f.mjs` +are optional tools rather than required companions. -Both remain type-only modules and use named `import type { ... }` imports. Do not -leave a rule that only governs `fjs/` while root-level authored `.mjs` files such -as `todo/proof.f.mjs` remain outside the convention. +Authored TypeScript type modules remain type-only and use named +`import type { ... }` imports. Do not leave a rule that only governs `fjs/` while +root-level authored `.mjs` files such as `todo/proof.f.mjs` remain outside the +convention. ### Tasks -- [ ] Document `types.ts`, `private.ts`, and `meta.f.mjs` beside the existing - JavaScript/FunctionalScript file conventions, including host `.mjs` and - descriptive companions. +- [ ] Document `private.ts` and `meta.f.mjs` as optional tools for cleaning the + public declaration/API surface, not required companion files. - [ ] Document and preserve the dependency order - `meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs`. -- [ ] Treat the placement table as a default design guide, not a mechanical rule; - analyze recursive or otherwise constrained metadata case by case before - introducing exceptions or reverse dependencies. + `meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs` + for the roles that are present. +- [ ] Treat file placement as a design guide, not a mechanical rule; analyze + recursive or otherwise constrained cases before introducing files, + exceptions, or reverse dependencies. - [ ] Update root `AGENTS.md` to prohibit file-scope JSDoc `@typedef` in every authored `.mjs` file anywhere in the repository. -- [ ] Update `fjs/AGENTS.md` to allow `types.ts` and `private.ts` as the authored - TypeScript type-module roles and document the public-declaration-closure and - dependency-order rules for `fjs/`. +- [ ] Update `fjs/AGENTS.md` to document `types.ts`, optional `private.ts`, the + optional `meta.f.mjs` role, the public-declaration-closure rule, and the + dependency-order rule for `fjs/`. - [ ] Update `fjs/fsc/README.md` and delete or narrow `todo/blocked/jsdoc-typedef-strip-internal.md` so they no longer prescribe a conflicting private-JSDoc strategy. -- [ ] Prohibit file-scope JSDoc `@typedef` in every authored `.mjs` repository-wide; - allow function-local `@typedef` everywhere. +- [ ] Prohibit file-scope JSDoc `@typedef` in every authored `.mjs` + repository-wide; allow function-local `@typedef` everywhere. - [ ] Migrate existing authored `.mjs` violations outside `fjs/`, including - `todo/proof.f.mjs`, using the same placement rules. + `todo/proof.f.mjs`, using the same public-boundary and layering principles. - [ ] Keep the leading `_` convention for every private type and private runtime metadata constant name. - [ ] Move public file-scope named types from authored `.mjs` JSDoc into @@ -398,9 +404,9 @@ as `todo/proof.f.mjs` remain outside the convention. - [ ] Keep or inline every private `_` helper required transitively by any shipped public declaration in `types.ts`, including helpers appearing in exported runtime-value/function signatures. -- [ ] Move only other implementation-private file-scope types to `private.ts`; - do not create reverse dependency edges or public-declaration -> `private.ts` - dependencies. +- [ ] Use `private.ts` only when separating implementation-private file-scope + types from the public declaration closure is the cleanest solution; do not + create it mechanically or create reverse/public-declaration dependencies. - [ ] Review type assertions that currently reverse the dependency order. Move implementation-signature assertions downstream into proof files where possible; specifically, move the `fjs/effects/types.ts` assertions over @@ -415,45 +421,42 @@ as `todo/proof.f.mjs` remain outside the convention. preserve layering and move file-scope consistency asserts into proof functions. - [ ] Keep lexical type-proof typedefs inside their functions. -- [ ] Prefer `meta.f.mjs` for runtime metadata constants used to define/derive - TypeScript types when that placement preserves the dependency order, - including RTTI values, non-RTTI literal constants, and runtime-used tables; - prefix private ones with `_` even when they must be exported for - sibling-module access. Do not move ordinary implementation functions, or - recursively annotated metadata that would reverse the dependency order, - merely because a proof inspects their type. -- [ ] Move file-scope private proofs over metadata constants to `private.ts`, +- [ ] Use `meta.f.mjs` only when extracting runtime metadata/data improves the + public/type structure while preserving dependency order; private extracted + constants use `_`. Do not move ordinary implementation functions or + recursively annotated metadata merely because a proof inspects their type. +- [ ] Move file-scope private proofs over metadata/constants to `private.ts`, `types.ts`, or a downstream proof function according to the concrete dependency graph; do not force one placement mechanically. - [ ] Treat moves of public runtime constants to `meta.f.mjs` as breaking API - changes; update runtime importers and changelog, with no compatibility - re-exports. -- [ ] Require every import in `types.ts` and `private.ts` to use named - `import type { ... }`. + changes when such moves are chosen; update runtime importers and changelog, + with no compatibility re-exports. +- [ ] Require every import in authored TypeScript type modules (`types.ts` and + `private.ts` when present) to use named `import type { ... }`. - [ ] Update Node and Deno coverage filters to include `meta.f.mjs`; emergent testing already loads it, and the existing coverage thresholds apply. -- [ ] Keep `private.ts` in normal TypeScript checking without runtime JS emit. -- [ ] Make deletion of generated `private.d.ts` the final `prepack` step. +- [ ] When `private.ts` is used, keep it in normal TypeScript checking without + runtime JS emit and delete generated `private.d.ts` files as the final + `prepack` step. - [ ] Do not rewrite/post-process emitted declarations to remove retained JSDoc `@import` comments; they are non-semantic in `.d.ts` / `.d.mts`. -- [ ] Inspect the `npm pack` artifact for private files and **semantic** private - declaration dependencies, ignoring retained comments. -- [ ] Add a fixture covering: +- [ ] Inspect the `npm pack` artifact for unshipped private declaration + dependencies, ignoring retained comments. +- [ ] Add fixtures covering both optional tools and cases that do not need them: - a private helper required by a public type alias; - a private helper required by an exported runtime value/function declaration (the `_SortedArray`/`find` shape); - - an implementation-private type in `private.ts`; + - an implementation-private type separated with `private.ts`; - a function-local typedef depending on a lexical value; - an implementation-function signature assertion placed downstream in a - proof rather than creating a `types.ts -> module.f.mjs` dependency; + proof rather than introducing `private.ts`/`meta.f.mjs` unnecessarily; - a recursive metadata case whose named type annotation keeps it in `module.f.mjs` while its consistency assert moves into a proof function; - a FunctionalScript descriptive companion such as `testlib.f.mjs` whose - former file-scope typedef is moved to the appropriate TypeScript file; - - a non-FunctionalScript authored `.mjs` file whose former file-scope - typedef is moved to the appropriate TypeScript file; + former file-scope typedef is moved to the appropriate place; + - a non-FunctionalScript authored `.mjs` case; - a root/outside-`fjs/` authored `.mjs` case; - - `meta.f.mjs` with RTTI, literal, runtime-used, and private `_` constants. + - a case where `meta.f.mjs` is useful, including a private `_` constant. - [ ] Include a retained JSDoc `@import ... './private.ts'` comment in an emitted declaration fixture and verify the clean consumer succeeds without `private.ts`; this proves comments do not create package dependencies. @@ -464,14 +467,16 @@ as `todo/proof.f.mjs` remain outside the convention. ### Acceptance criteria +- The public declaration/API surface is clean and self-contained; `private.ts` + and `meta.f.mjs` are optional tools, not required companion files. - The dependency order `meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs` - is preserved; type assertions do not create reverse edges merely for - convenience. -- File placement follows the dependency order and concrete design needs rather - than a blanket syntactic rule; recursive metadata may remain in `module.f.mjs` - when moving it to `meta.f.mjs` would require a `types.ts` dependency, with - consistency assertions moved downstream when appropriate. + is preserved for roles that are present; type assertions do not create reverse + edges merely for convenience. +- File placement follows concrete design needs rather than a blanket syntactic + rule; recursive metadata may remain in `module.f.mjs` when moving it to + `meta.f.mjs` would require a `types.ts` dependency, with consistency assertions + moved downstream when appropriate. - No authored `.mjs` file anywhere in the repository contains a file-scope JSDoc `@typedef`, regardless of directory, basename, FunctionalScript marker, or role. - Function-local JSDoc `@typedef` is allowed everywhere; private names keep `_` @@ -479,37 +484,42 @@ as `todo/proof.f.mjs` remain outside the convention. - `types.ts` is the public declaration closure: public types plus any private helpers required transitively to express shipped declarations of public types or exported runtime values/functions. -- `private.ts` contains only implementation-private file-scope types outside the - public declaration closure and is expected to be used sparingly where - `types.ts` already describes most of a module's type surface. -- `types.ts` and every packed public declaration are independent of `private.ts`. +- If present, `private.ts` contains only implementation-private file-scope types + outside the public declaration closure. It is used only when that separation + improves the design. +- `types.ts` and every packed public declaration are independent of unshipped + private type modules. - Assertions about ordinary implementation-function signatures live downstream of `module.f.mjs` (normally inside proof functions with function-local typedefs in `proof.f.mjs`) rather than forcing those functions into `meta.f.mjs` or importing implementation functions into `types.ts`. -- Every import in `types.ts` and `private.ts` uses named `import type { ... }`. -- Runtime metadata constants used to define/derive TypeScript types normally live - in `meta.f.mjs` when the dependency order permits it. Private constants use - leading `_` even when exported for sibling-module access; `_` marks them private - by contract. Emergent testing loads `meta.f.mjs`, Node and Deno coverage filters - include it, and the existing coverage thresholds apply; this convention does - not prescribe how developers satisfy those thresholds. -- Moving public types to `types.ts` and public runtime metadata to `meta.f.mjs` - are breaking migrations: importers and changelog are updated and no - compatibility re-exports preserve old entry points. -- Declaration emit may create `private.d.ts`; final-`prepack` cleanup removes it +- Every import in authored TypeScript type modules uses named + `import type { ... }`. +- When `meta.f.mjs` is used, it contains runtime metadata/data extracted because + that improves the type/public structure while preserving dependency order. + Private constants use leading `_` even when exported for sibling-module + access; `_` marks them private by contract. Emergent testing loads + `meta.f.mjs`, Node and Deno coverage filters include it, and the existing + coverage thresholds apply; this convention does not prescribe how developers + satisfy those thresholds. +- Moving public types to `types.ts` or public runtime metadata to `meta.f.mjs` + are breaking migrations when those moves occur: importers and changelog are + updated and no compatibility re-exports preserve old entry points. +- If declaration emit creates `private.d.ts`, final-`prepack` cleanup removes it before package contents are selected. - Emitted declarations are not text-postprocessed: retained JSDoc `@import` comments may mention `private.ts` and are allowed because they do not create a TypeScript module dependency. -- The packed tarball contains neither `private.ts` nor `private.d.ts`, and no - packed declaration has a semantic dependency on the private module. +- The packed tarball contains no unshipped private type artifacts required by + public declarations, and no packed declaration has a semantic dependency on + an unshipped private module. - Public declaration helpers retained in `types.ts` remain self-contained and resolvable from shipped declarations, including helpers used by exported runtime-value/function signatures. - Root `AGENTS.md` documents the repository-wide all-authored-`.mjs` file-scope typedef prohibition, while `fjs/AGENTS.md` documents the `fjs/`-specific - `types.ts` / `private.ts` roles, declaration-closure rule, and dependency order. + declaration-closure/dependency-order rules and the optional `private.ts` / + `meta.f.mjs` tools. - `fjs/fsc/README.md` and the blocked `@internal`/`stripInternal` TODO no longer prescribe a conflicting private-JSDoc strategy. - A clean TypeScript consumer type-checks successfully against the packed From e785b3b61be62435799cc34a93d0da827f3be554 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 10:55:32 -0700 Subject: [PATCH 070/370] todo: make metadata an optional submodule --- fjs/todo/separate-private-types.md | 622 ++++++++--------------------- 1 file changed, 177 insertions(+), 445 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 0f5285f21..1bc19bdcb 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -5,534 +5,266 @@ ### Problem -FunctionalScript currently mixes named types with authored JavaScript source. -Common examples are: +TypeScript declaration emit turns file-scope JSDoc `@typedef`s in authored +`.mjs` files into declaration aliases. Implementation-private `_` types therefore +leak into generated `.d.ts` / `.d.mts` files and add noise to the public surface. -```text -module.f.mjs # FunctionalScript implementation + file-scope JSDoc typedefs -module.mjs # host integration + file-scope JSDoc typedefs -proof.f.mjs # proofs + file-scope JSDoc typedefs -types.ts # public types + private helpers -``` - -Other authored JavaScript companions, such as `testlib.f.mjs`, can contain the -same file-scope typedefs and are subject to the same declaration emit. The same -problem also exists outside `fjs/`; for example, `todo/proof.f.mjs` is an authored -`.mjs` file with a file-scope typedef. TypeScript turns file-scope JSDoc -`@typedef`s in authored `.mjs` files into exported aliases, so -implementation-private names leak into generated `.d.mts` files. The existing -leading-`_` convention marks those names private by contract, but the declarations -still contain noise and make the source/package boundary less clear. - -The requirement is to keep the public declaration/API surface clean and -self-contained. `private.ts` and `meta.f.mjs` are **optional tools** for reaching -that result; they are not file roles that every module must introduce. - -### Proposal - -Use these file roles when they improve the concrete design: - -```text -module.f.mjs # FunctionalScript implementation -module.mjs # host integration, when needed -proof.f.mjs # FunctionalScript proofs -proof.mjs # host proofs, when needed -meta.f.mjs # optional runtime metadata/data extracted to support type structure -types.ts # public declaration closure -private.ts # optional implementation-private file-scope TypeScript -``` +The requirement is a clean, self-contained public declaration/API boundary. +`private.ts` and subordinate modules such as `meta/module.f.mjs` are **tools** for +reaching that result, not required companion files. -The design target is the public boundary, not the presence of particular files: +### Rules -- `types.ts` describes the public declaration closure; -- use `private.ts` when moving implementation-private file-scope types out of the - public declaration surface makes the structure cleaner; -- use `meta.f.mjs` when extracting runtime metadata/data lets TypeScript types or - proofs depend on it without reversing the dependency order; -- do not create either file mechanically when a simpler organization already - keeps the public surface clean. +#### No file-scope typedefs in authored `.mjs` -No authored `.mjs` file anywhere in the repository may declare a **file-scope** -JSDoc `@typedef`, regardless of directory, basename, or whether the file is -FunctionalScript. This includes `module.f.mjs`, `module.mjs`, `proof.f.mjs`, -`proof.mjs`, `meta.f.mjs`, `testlib.f.mjs`, root-level or `todo/` `.mjs` files, -and other descriptive companions. Function-local typedefs remain allowed as -described below. +No authored `.mjs` anywhere in the repository may contain a **file-scope** JSDoc +`@typedef`, regardless of directory, basename, or whether the file is +FunctionalScript. This includes `module.f.mjs`, `proof.f.mjs`, host `.mjs` files, +descriptive companions such as `testlib.f.mjs`, and root/`todo/` files such as +`todo/proof.f.mjs`. -Private type and runtime constant names continue to start with `_`. - -#### Dependency order - -When these roles are present, keep the source dependency order: - -```text -meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs -``` - -The arrow points from a dependency to a dependent: a file may depend on files to -its left, but moving a type proof must not introduce a reverse edge merely to -keep the proof near the declaration it checks. The order is a layering rule, not -a requirement that every file or edge exists. - -The file-placement conventions below are defaults, not a mechanical classifier. -Analyze concrete cases and prefer the simplest organization that preserves this -dependency order and the clean public boundary. In particular, do not force a -recursive RTTI constant into `meta.f.mjs` when expressing its recursion requires -a named annotation from `types.ts`; keeping that constant downstream can be -cleaner than creating a `meta.f.mjs -> types.ts` reverse edge. - -Place assertions in the earliest layer that can legitimately see everything they -assert without reversing this order: - -- invariants entirely inside the public type model belong in `types.ts`; -- implementation-private type invariants may use `private.ts` when that is the - cleanest place and they do not need downstream implementation values; -- assertions about `module.f.mjs` implementations, including function signatures, - belong downstream in `proof.f.mjs`, normally as function-local typedef proofs; -- host-specific implementation assertions belong downstream in `proof.mjs`. - -For example, `fjs/effects/types.ts` currently imports `step`, `catchStep`, -`resultStep`, `mapStep`, `resultMapStep`, and `unwrapStep` from `module.f.mjs` -only to assert their inferred signatures with `ReturnType`. Those -assertions verify the implementation layer, so the migration should move them to -`proof.f.mjs` rather than move the implementation functions into `meta.f.mjs`. -A representative group of compile-time-only checks can live inside one proof -function so every typedef remains lexical: +Function-local typedefs remain allowed. This is especially useful for compile-time +proofs that need lexical or downstream runtime values: ```js -signatures: () => { - /** - * @typedef {Assert>, - * Effect<_AddOp | _MulOp, string, NotImplemented | string> - * >>} _StepSig - */ - - /** @typedef {Assert>, Effect<...>>>} _CatchStepSig */ +const signatures = () => { + /** @typedef {Assert>, Effect<...>>>} _Step */ + /** @typedef {Assert>, Effect<...>>>} _CatchStep */ } ``` -Recursive metadata needs the same case-by-case treatment. Two current examples -illustrate the intended approach: - -- `fjs/media/revision`: `LockMap` / `LockSchema` stay in `types.ts`; the recursive - `lock` RTTI constant may stay in `module.f.mjs` because its initializer needs - the named `LockSchema` annotation from `types.ts`; the `Assert>` - consistency checks that currently make `types.ts` import `module.f.mjs` move - downstream into a function in `proof.f.mjs`. -- `fjs/edag`: recursive RTTI such as `exp` may stay in `module.f.mjs` when its - explicit annotation depends on the public EDAG types; file-scope consistency - assertions such as `Assert` move into one or more proof functions so - the RTTI/type relationship is still pinned without leaking typedefs or - reversing the dependency order. - -These examples do not establish a special rule for every recursive type. They -show the process: inspect the cycle, preserve the dependency direction, move -verification downstream when useful, and introduce `private.ts` / `meta.f.mjs` -only when they simplify the result. Narrow exceptions may still be needed after -the concrete case has been analyzed. +Private type and runtime constant names continue to use a leading `_`. #### Public declaration closure -`types.ts` is primarily the public type API, but it may contain private `_` -helpers when they are required to express any shipped public declaration. -"Public declaration" includes both exported type aliases and declarations of -exported runtime values/functions. +`types.ts` describes the public declaration closure: -The default placement guide is: +- public types; +- private `_` helpers required transitively by shipped public declarations, + including declarations of exported runtime functions/values. -```text -public type -> types.ts -private `_` helper used by any public declaration -> types.ts -other file-scope private `_` type -> private.ts, when useful -function-local typedef -> allowed in place -runtime metadata/data extracted for type structure -> meta.f.mjs, when useful -``` +For example, if an exported `find` declaration contains `_SortedArray`, then +`_SortedArray` is part of the public declaration closure and stays in `types.ts` +(or is inlined). Moving it to an unshipped private module would make the public +declaration incomplete. -For example, a helper used by a public type stays in `types.ts`: +`types.ts` must not depend on `private.ts`. -```ts -type _Tuple = - N extends R['length'] ? R : _Tuple +`private.ts` is optional. Use it only when separating implementation-private +file-scope types outside the public declaration closure makes the design cleaner. +Do not create it mechanically for every `_` name. -export type Tuple = _Tuple -``` +#### Dependency order -The same rule applies when a helper appears in an exported value declaration. -For example, if declaration emit for an exported `find` contains: +Preserve the dependency direction for the roles that exist: -```ts -export const find: (cmp: Cmp) => - (value: T) => (array: _SortedArray) => T | null +```text +meta/module.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs ``` -then `_SortedArray` is part of the public declaration closure and must remain in -`types.ts` (or be inlined into the public declaration). Moving it to -`private.ts` would make a shipped declaration depend on a declaration module -that packaging removes. - -`types.ts` must never import `private.ts`. If moving a private helper to -`private.ts` would create a `types.ts -> private.ts` edge or cause any generated -public declaration to reference `private.ts`, keep or inline that helper in -`types.ts` instead. +The arrow points from dependency to dependent. This is a layering guide, not a +requirement that every file or edge exists. -`private.ts` therefore should be uncommon. It is a tool for separating -implementation-private file-scope types that are outside the public declaration -closure; it is not a required companion and not a mechanical destination for -every `_` name. +Move verification downstream before moving implementation upstream. For example, +`fjs/effects/types.ts` currently imports implementation functions only to assert +`ReturnType` signatures. Those assertions verify `module.f.mjs`, so +move them into one or more proof functions in `proof.f.mjs`; keep the functions +in `module.f.mjs`. -#### Function-local typedefs +Analyze constrained and recursive cases individually rather than inventing broad +exceptions: -Function-local JSDoc `@typedef` declarations are allowed in any authored source -file. They may refer to lexical values that cannot be named from a sibling -TypeScript file. +- `fjs/media/revision`: `LockMap` / `LockSchema` can remain in `types.ts`; recursive + `lock` can remain in `module.f.mjs` when it requires the named `LockSchema` + annotation; move `Assert>` consistency checks into a proof function. +- `fjs/edag`: recursive RTTI such as `exp` can remain in `module.f.mjs` when its + annotation depends on public EDAG types; move file-scope consistency asserts + into proof functions. -For example: +The goal is to preserve the dependency direction and simplify the public surface, +not to satisfy a mechanical file-placement rule. -```js -const proof = () => { - const orConst = or(42, string) - /** @typedef {Assert>>} _OrConst */ -} -``` +### Optional metaprogramming submodule -Callback-local type proofs are also valid: +When declarative runtime constants are shared between TypeScript and runtime code, +it can be useful to split them into a normal subordinate module, for example: -```js -({ kind }) => { - /** @typedef {Assert>} _Kind */ - return kind -} +```text +meta/ + module.f.mjs ``` -Private function-local typedefs keep the leading `_`. Declaration validation -must verify that function-local typedefs remain lexical and do not escape as -exported aliases in generated `.d.ts` / `.d.mts` files. - -#### `meta.f.mjs` +`meta` here means **metaprogramming**: declarative definitions of types/schema-like +information that are useful at both compile time (TypeScript through `typeof`, +RTTI conversion, indexed access, etc.) and runtime. -`meta.f.mjs` is an optional tool for extracting runtime metadata/data when doing -so improves the type/declaration boundary. Typical cases are RTTI descriptors, -`as const`-style data, and lookup tables whose literal shape is used to define or -derive TypeScript types. +Typical examples are: -Do not create `meta.f.mjs` merely because a runtime value participates in a type -proof. A recursive metadata constant that needs a named annotation from -`types.ts` may remain in `module.f.mjs`; ordinary implementation functions stay -in `module.f.mjs` even when a proof inspects their signature with `typeof`, -`ReturnType`, or `Parameters`. Move verification downstream when that is the -cleaner solution. The `media/revision`, `edag`, and `effects` examples above are -the current models for this analysis. - -When `meta.f.mjs` is useful, examples include RTTI values: - -```ts -import type { type } from './meta.f.mjs' +- RTTI/schema constants; +- `as const`-style literal data; +- declarative lookup tables whose literal shape defines or constrains types. -export type Value = Ts -``` - -ordinary literal constants: - -```ts -import type { statuses } from './meta.f.mjs' - -export type Status = typeof statuses[number] -``` +This is only a suggestion. Do not create `meta/` merely because a runtime value +appears in a type proof. Ordinary implementation functions stay in +`module.f.mjs`; recursively annotated metadata may also stay there when moving it +would reverse the dependency direction. -and normal runtime tables whose type is asserted: +A private constant exported from `meta/module.f.mjs` for sibling-module linkage +uses a leading `_`: ```js -// meta.f.mjs +// meta/module.f.mjs export const _framingKeywords = /** @type {const} */ (['import', 'const', 'export', 'default', 'from']) ``` ```ts -// private.ts -import type { _framingKeywords } from './meta.f.mjs' - -type _KeywordsAreComplete = - Assert> +// private.ts or types.ts +import type { _framingKeywords } from './meta/module.f.mjs' ``` ```js // module.f.mjs -import { _framingKeywords } from './meta.f.mjs' +import { _framingKeywords } from './meta/module.f.mjs' ``` -Private constants in `meta.f.mjs` use the same leading-`_` API convention as -private types. They may need to be exported so sibling runtime or TypeScript -modules can name them, but that export is module linkage rather than public API: -consumers must not depend on `_`-prefixed constants directly. Renaming or removing -such a name is not a breaking change solely because it was exported. As with -private types, changes that alter an actual public runtime/type contract still -follow the normal breaking-change rules. - -Runtime code imports values from `meta.f.mjs` normally. Authored TypeScript type -modules use only named type-only imports: +Exportability is linkage, not API status: `_` means consumers must not depend on +the name. Renaming/removing it is not breaking solely because it is exported. -```ts -import type { PublicType } from './types.ts' -import type { metadataValue } from './meta.f.mjs' -``` +Because `meta/module.f.mjs` is just another `module.f.mjs`, existing tooling +already handles it: -Do not use runtime `import { ... }`, namespace imports, or side-effect imports in -`types.ts` or `private.ts`. +- emergent testing loads it as `*.f.mjs`; +- the existing Node `**/module.f.mjs` coverage filter includes it; +- the existing Deno `.*module\\.f\\.mjs` filter includes it. -When present, `meta.f.mjs` is executable FunctionalScript source. Emergent -testing already loads every `*.f.mjs` during normal test discovery, including -modules without a `proof` export. Add `meta.f.mjs` to the Node and Deno coverage -filters so the existing coverage thresholds apply to it. How a particular -metadata module satisfies those thresholds is left to its developer; this -convention does not prescribe proof imports, calls, or other coverage-specific -implementation choices. +No special metadata filename or coverage rule is needed. -#### Breaking migration; no compatibility re-exports +### Breaking migrations -Moving a public file-scope type from any authored `.mjs` file to `types.ts` -changes its public type import path. Moving a public runtime constant from -`module.f.mjs` to `meta.f.mjs` changes its runtime import path. +Moving an existing public type from an authored `.mjs` declaration surface to +`types.ts` changes its public type import path. Moving an existing public runtime +constant into a subordinate module such as `meta/module.f.mjs` changes its runtime +import path. -Treat either move as an intentional breaking API change when it occurs: - -```text -public type: ./.mjs -> ./types.ts -public metadata: ./module.f.mjs -> ./meta.f.mjs -``` +When such moves are chosen, treat them as intentional breaking changes: -Update every repository importer and the changelog. Do not preserve old entry -points with compatibility typedefs, exports, or re-exports. +- update every repository importer; +- update the changelog; +- do **not** add compatibility typedefs, exports, or re-exports to preserve the + old entry point. -#### Declaration emission and packaging +Private `_` names are not public API merely because declaration emit or module +linkage exposes them. -If `private.ts` is used, it remains in the normal TypeScript program so its -declarations and all JSDoc `@import` users are checked. Normal declaration emit -may therefore produce an intermediate `private.d.ts`. +### Declaration emission and packaging -Do not try to exclude `private.ts` from the TypeScript program. Instead, when -such files exist, make private-declaration cleanup the final `prepack` step: +If `private.ts` is used, keep it in the normal TypeScript program so source users +are checked. Declaration emit may therefore create an intermediate +`private.d.ts`. -1. emit declarations; -2. run the existing declaration round-trip type-check; -3. delete generated `private.d.ts` files as the final `prepack` command; -4. let `npm pack` select files after `prepack` completes; -5. inspect the actual tarball; -6. install the tarball in a clean TypeScript consumer and type-check it. +Do not try to exclude `private.ts` from checking. Instead delete generated +`private.d.ts` files as the final `prepack` step, after declaration emit and the +existing declaration round-trip check, before package contents are selected. -Conceptually: - -```text -tsc --noEmit false --emitDeclarationOnly && tsc && -``` - -Do **not** rewrite or post-process emitted declaration text. TypeScript may retain -source JSDoc comments such as: +Do **not** rewrite/post-process emitted declaration text. TypeScript may retain a +source comment such as: ```js /** @import { _Private } from './private.ts' */ ``` -inside an emitted `.d.ts` / `.d.mts`. In a declaration file this is a comment, -not a TypeScript import or module dependency, so it may remain after -`private.d.ts` is deleted. - -Validation of the packed artifact must prove: - -- authored `private.ts` and generated `private.d.ts` are not shipped when those - files are used during source checking; -- no packed `.d.ts` / `.d.mts` has a **semantic TypeScript dependency** on a - private type module that is not shipped. +inside an emitted declaration. In `.d.ts` / `.d.mts` this is only a comment, not +a TypeScript module dependency, so it may remain after the private declaration is +removed. -A raw text search for `private.ts` / `@import` is therefore incorrect because it -would reject harmless retained comments. If a structural scan is used, it must -ignore comments and reject only actual declaration syntax that creates a module -dependency. The clean-consumer TypeScript check is the final semantic validation. -References to packaged `meta.f.mjs` are allowed. +Package validation must check semantic dependencies, not raw text: -#### Repository-policy reconciliation +- no authored/generated private type artifact that is intended to be unshipped is + present in the tarball; +- no packed declaration semantically depends on an unshipped private type module; +- a clean TypeScript consumer installed from the tarball type-checks successfully. -[`../fsc/README.md`](../fsc/README.md) currently documents the leading `_` as an -interim API contract for private JSDoc typedefs that TypeScript leaks into emitted -declarations. The upstream blocker is -[microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407), -and the wait-for-`@internal`/`stripInternal` strategy is tracked in -[`../../todo/blocked/jsdoc-typedef-strip-internal.md`](../../todo/blocked/jsdoc-typedef-strip-internal.md). +### Repository policy -That policy remains authoritative until this migration is implemented. When this -TODO lands, update `fjs/fsc/README.md` and delete or narrow the blocked TODO so -the repository has one private-type strategy. +When this TODO is implemented: -The `.mjs` typedef prohibition is repository-wide, so the implementation must -also update the root [`../../AGENTS.md`](../../AGENTS.md). The root policy should -state that no authored `.mjs` anywhere in the repository may contain a file-scope -JSDoc `@typedef`. `fjs/AGENTS.md` should then document the additional `fjs/`-specific -file roles and dependency order, making clear that `private.ts` and `meta.f.mjs` -are optional tools rather than required companions. +- update root `AGENTS.md` with the repository-wide rule that authored `.mjs` files + may not contain file-scope JSDoc `@typedef`; +- update `fjs/AGENTS.md` with the public-declaration-closure rule, optional + `private.ts`, optional subordinate metaprogramming modules such as + `meta/module.f.mjs`, and the dependency-order guidance; +- update `fjs/fsc/README.md` and delete or narrow + `todo/blocked/jsdoc-typedef-strip-internal.md` so the repository does not keep + two conflicting private-type strategies. -Authored TypeScript type modules remain type-only and use named -`import type { ... }` imports. Do not leave a rule that only governs `fjs/` while -root-level authored `.mjs` files such as `todo/proof.f.mjs` remain outside the -convention. +Authored TypeScript type modules (`types.ts`, and `private.ts` when present) remain +type-only and use named `import type { ... }` imports. ### Tasks -- [ ] Document `private.ts` and `meta.f.mjs` as optional tools for cleaning the - public declaration/API surface, not required companion files. -- [ ] Document and preserve the dependency order - `meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs` - for the roles that are present. -- [ ] Treat file placement as a design guide, not a mechanical rule; analyze - recursive or otherwise constrained cases before introducing files, - exceptions, or reverse dependencies. -- [ ] Update root `AGENTS.md` to prohibit file-scope JSDoc `@typedef` in every - authored `.mjs` file anywhere in the repository. -- [ ] Update `fjs/AGENTS.md` to document `types.ts`, optional `private.ts`, the - optional `meta.f.mjs` role, the public-declaration-closure rule, and the - dependency-order rule for `fjs/`. -- [ ] Update `fjs/fsc/README.md` and delete or narrow - `todo/blocked/jsdoc-typedef-strip-internal.md` so they no longer prescribe - a conflicting private-JSDoc strategy. -- [ ] Prohibit file-scope JSDoc `@typedef` in every authored `.mjs` - repository-wide; allow function-local `@typedef` everywhere. -- [ ] Migrate existing authored `.mjs` violations outside `fjs/`, including - `todo/proof.f.mjs`, using the same public-boundary and layering principles. -- [ ] Keep the leading `_` convention for every private type and private runtime - metadata constant name. -- [ ] Move public file-scope named types from authored `.mjs` JSDoc into - `types.ts` as a breaking migration; update importers and changelog. -- [ ] Keep or inline every private `_` helper required transitively by any - shipped public declaration in `types.ts`, including helpers appearing in - exported runtime-value/function signatures. -- [ ] Use `private.ts` only when separating implementation-private file-scope - types from the public declaration closure is the cleanest solution; do not - create it mechanically or create reverse/public-declaration dependencies. -- [ ] Review type assertions that currently reverse the dependency order. Move - implementation-signature assertions downstream into proof files where - possible; specifically, move the `fjs/effects/types.ts` assertions over - `step` / `catchStep` / `resultStep` / `mapStep` / `resultMapStep` / - `unwrapStep` into one or more proof functions with function-local typedefs - in `fjs/effects/proof.f.mjs`. -- [ ] Review recursive metadata cases individually. For `fjs/media/revision`, - keep the recursive `lock` RTTI in `module.f.mjs` if its `LockSchema` - annotation requires `types.ts`, and move the `LockMap` / `LockField` - consistency asserts into a proof function. Apply the same analysis to - recursive EDAG RTTI such as `exp`: keep it in `module.f.mjs` when needed to - preserve layering and move file-scope consistency asserts into proof - functions. -- [ ] Keep lexical type-proof typedefs inside their functions. -- [ ] Use `meta.f.mjs` only when extracting runtime metadata/data improves the - public/type structure while preserving dependency order; private extracted - constants use `_`. Do not move ordinary implementation functions or - recursively annotated metadata merely because a proof inspects their type. -- [ ] Move file-scope private proofs over metadata/constants to `private.ts`, - `types.ts`, or a downstream proof function according to the concrete - dependency graph; do not force one placement mechanically. -- [ ] Treat moves of public runtime constants to `meta.f.mjs` as breaking API - changes when such moves are chosen; update runtime importers and changelog, - with no compatibility re-exports. -- [ ] Require every import in authored TypeScript type modules (`types.ts` and - `private.ts` when present) to use named `import type { ... }`. -- [ ] Update Node and Deno coverage filters to include `meta.f.mjs`; emergent - testing already loads it, and the existing coverage thresholds apply. -- [ ] When `private.ts` is used, keep it in normal TypeScript checking without - runtime JS emit and delete generated `private.d.ts` files as the final +- [ ] Document the repository-wide prohibition on file-scope JSDoc `@typedef` in + authored `.mjs`; allow function-local typedefs. +- [ ] Migrate existing violations, including authored `.mjs` outside `fjs/` such + as `todo/proof.f.mjs`. +- [ ] Keep `types.ts` as the public declaration closure; retain/in-line private + helpers required by public declarations. +- [ ] Use `private.ts` only where separating implementation-private file-scope + types improves the design. +- [ ] Preserve the dependency direction shown above; move verification downstream + when that is cleaner. +- [ ] Move the `fjs/effects/types.ts` implementation-signature asserts into proof + functions in `fjs/effects/proof.f.mjs`. +- [ ] Review recursive cases individually, including `fjs/media/revision` and + `fjs/edag`; keep recursive RTTI in `module.f.mjs` when required by layering + and move consistency asserts into proof functions. +- [ ] Where useful, split declarative compile-time/runtime constants into a normal + subordinate module such as `meta/module.f.mjs`; do not require it. +- [ ] Preserve leading `_` for private types and private runtime constants. +- [ ] Treat chosen public import-path moves as breaking changes with no + compatibility re-exports. +- [ ] If `private.ts` is used, delete generated `private.d.ts` as the final `prepack` step. -- [ ] Do not rewrite/post-process emitted declarations to remove retained JSDoc - `@import` comments; they are non-semantic in `.d.ts` / `.d.mts`. -- [ ] Inspect the `npm pack` artifact for unshipped private declaration - dependencies, ignoring retained comments. -- [ ] Add fixtures covering both optional tools and cases that do not need them: - - a private helper required by a public type alias; - - a private helper required by an exported runtime value/function - declaration (the `_SortedArray`/`find` shape); - - an implementation-private type separated with `private.ts`; - - a function-local typedef depending on a lexical value; - - an implementation-function signature assertion placed downstream in a - proof rather than introducing `private.ts`/`meta.f.mjs` unnecessarily; - - a recursive metadata case whose named type annotation keeps it in - `module.f.mjs` while its consistency assert moves into a proof function; - - a FunctionalScript descriptive companion such as `testlib.f.mjs` whose - former file-scope typedef is moved to the appropriate place; - - a non-FunctionalScript authored `.mjs` case; - - a root/outside-`fjs/` authored `.mjs` case; - - a case where `meta.f.mjs` is useful, including a private `_` constant. -- [ ] Include a retained JSDoc `@import ... './private.ts'` comment in an emitted - declaration fixture and verify the clean consumer succeeds without - `private.ts`; this proves comments do not create package dependencies. -- [ ] Verify the normal test runner loads fixture `meta.f.mjs` and Node/Deno - coverage includes it under the existing thresholds. -- [ ] Verify source checking, declaration emit/cleanup, Node+Deno coverage, - packing, and clean-consumer type checking. +- [ ] Do not text-postprocess emitted declarations; validate semantic private + dependencies and clean-consumer type checking instead. +- [ ] Add fixtures/examples covering: public-declaration helpers, optional + `private.ts`, function-local proof typedefs, recursive RTTI kept in + `module.f.mjs`, optional `meta/module.f.mjs`, retained non-semantic JSDoc + comments, and authored `.mjs` outside `fjs/`. +- [ ] Update root/fjs policy documentation and reconcile the old `_` leak policy. ### Acceptance criteria -- The public declaration/API surface is clean and self-contained; `private.ts` - and `meta.f.mjs` are optional tools, not required companion files. -- The dependency order - `meta.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs` - is preserved for roles that are present; type assertions do not create reverse - edges merely for convenience. -- File placement follows concrete design needs rather than a blanket syntactic - rule; recursive metadata may remain in `module.f.mjs` when moving it to - `meta.f.mjs` would require a `types.ts` dependency, with consistency assertions - moved downstream when appropriate. -- No authored `.mjs` file anywhere in the repository contains a file-scope JSDoc - `@typedef`, regardless of directory, basename, FunctionalScript marker, or role. -- Function-local JSDoc `@typedef` is allowed everywhere; private names keep `_` - and do not escape as exported declaration aliases. -- `types.ts` is the public declaration closure: public types plus any private - helpers required transitively to express shipped declarations of public types - or exported runtime values/functions. -- If present, `private.ts` contains only implementation-private file-scope types - outside the public declaration closure. It is used only when that separation - improves the design. -- `types.ts` and every packed public declaration are independent of unshipped - private type modules. -- Assertions about ordinary implementation-function signatures live downstream - of `module.f.mjs` (normally inside proof functions with function-local typedefs - in `proof.f.mjs`) rather than forcing those functions into `meta.f.mjs` or - importing implementation functions into `types.ts`. -- Every import in authored TypeScript type modules uses named - `import type { ... }`. -- When `meta.f.mjs` is used, it contains runtime metadata/data extracted because - that improves the type/public structure while preserving dependency order. - Private constants use leading `_` even when exported for sibling-module - access; `_` marks them private by contract. Emergent testing loads - `meta.f.mjs`, Node and Deno coverage filters include it, and the existing - coverage thresholds apply; this convention does not prescribe how developers - satisfy those thresholds. -- Moving public types to `types.ts` or public runtime metadata to `meta.f.mjs` - are breaking migrations when those moves occur: importers and changelog are - updated and no compatibility re-exports preserve old entry points. +- The public declaration/API surface is clean and self-contained. +- No authored `.mjs` anywhere in the repository contains a file-scope JSDoc + `@typedef`; function-local typedefs are allowed. +- `types.ts` contains the public declaration closure and does not depend on an + unshipped private type module. +- `private.ts`, when present, is an optional implementation tool rather than a + required companion. +- A subordinate module such as `meta/module.f.mjs`, when present, is an optional + metaprogramming/design tool rather than a special file role or requirement. +- The dependency direction is preserved; assertions do not create reverse edges + merely for convenience. +- Private types/constants use leading `_`, even when linkage requires an export. +- Existing `module.f.mjs` discovery and coverage rules automatically include + `meta/module.f.mjs`; no metadata-specific coverage convention exists. +- Chosen public import-path moves are breaking migrations with importers/changelog + updated and no compatibility re-exports. - If declaration emit creates `private.d.ts`, final-`prepack` cleanup removes it - before package contents are selected. -- Emitted declarations are not text-postprocessed: retained JSDoc `@import` - comments may mention `private.ts` and are allowed because they do not create a - TypeScript module dependency. -- The packed tarball contains no unshipped private type artifacts required by - public declarations, and no packed declaration has a semantic dependency on - an unshipped private module. -- Public declaration helpers retained in `types.ts` remain self-contained and - resolvable from shipped declarations, including helpers used by exported - runtime-value/function signatures. -- Root `AGENTS.md` documents the repository-wide all-authored-`.mjs` file-scope - typedef prohibition, while `fjs/AGENTS.md` documents the `fjs/`-specific - declaration-closure/dependency-order rules and the optional `private.ts` / - `meta.f.mjs` tools. -- `fjs/fsc/README.md` and the blocked `@internal`/`stripInternal` TODO no longer - prescribe a conflicting private-JSDoc strategy. -- A clean TypeScript consumer type-checks successfully against the packed - tarball after private artifacts are removed, including when retained comments - mention the removed private source path. + before packaging. +- Emitted declarations are not text-postprocessed; retained JSDoc `@import` + comments are allowed when they are non-semantic. +- The packed artifact has no semantic dependency on an unshipped private type + module, and a clean TypeScript consumer type-checks successfully. +- Root `AGENTS.md`, `fjs/AGENTS.md`, `fjs/fsc/README.md`, and the blocked + `@internal` TODO no longer prescribe conflicting rules. ### Related - [`../fsc/README.md`](../fsc/README.md) — current `_` leak-tolerance policy. -- [`../../AGENTS.md`](../../AGENTS.md) — root repository policy to update with the - all-authored-`.mjs` rule. -- [`../AGENTS.md`](../AGENTS.md) — `fjs/`-specific authored-TypeScript and file-role - policy to update. +- [`../../AGENTS.md`](../../AGENTS.md) — root repository policy to update. +- [`../AGENTS.md`](../AGENTS.md) — `fjs/`-specific file/dependency policy. - [`../../todo/blocked/jsdoc-typedef-strip-internal.md`](../../todo/blocked/jsdoc-typedef-strip-internal.md) — current wait-for-`@internal`/`stripInternal` strategy. - [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) From 0e7237140ad0e2b6242543977d672c3d35bcf243 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 11:04:49 -0700 Subject: [PATCH 071/370] ok --- fjs/bnf/data/module.f.mjs | 3 --- fjs/bnf/descent/module.f.mjs | 16 ++-------------- fjs/bnf/descent/private.ts | 11 ++++++++++- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/fjs/bnf/data/module.f.mjs b/fjs/bnf/data/module.f.mjs index 03dc91447..91073a1a3 100644 --- a/fjs/bnf/data/module.f.mjs +++ b/fjs/bnf/data/module.f.mjs @@ -14,7 +14,6 @@ * @module * * @import { DataRule, Rule as FRule, Sequence as FSequence } from '../types.ts' - * @import { StringMap } from '../../types/object/types.ts' * @import { StringSet } from '../../types/string_set/types.ts' * @import { _EmptyTagMap, _FRuleMap, _NewRule } from './private.ts' * @import { EmptyTag, Repeat, Rule, RuleSet, Sequence, Variant } from './types.ts' @@ -38,8 +37,6 @@ import { contains, set } from '../../types/string_set/module.f.mjs' */ export const isRepeat = rule => typeof rule === 'string' - - /** @type {(map: _EmptyTagMap) => (rule: Rule) => EmptyTag} */ const emptyTagOf = map => rule => { if (typeof rule === 'number') { diff --git a/fjs/bnf/descent/module.f.mjs b/fjs/bnf/descent/module.f.mjs index 871cdc2e5..5ccf906b9 100644 --- a/fjs/bnf/descent/module.f.mjs +++ b/fjs/bnf/descent/module.f.mjs @@ -25,12 +25,12 @@ * * @module * - * @import { _Failure } from './private.ts' + * @import { _Failure, _Result } from './private.ts' * @import { TerminalRange } from '../types.ts' * @import { Rule as DataRule, RuleSet, Sequence } from '../data/types.ts' * @import { Rule as FRule } from '../types.ts' * @import { List } from '../../types/list/types.ts' - * @import { Ast, AstResult, AstSequence, AstTag, Cursor } from '../matcher/types.ts' + * @import { Ast, AstSequence, AstTag, Cursor } from '../matcher/types.ts' * @import { CodePointMeta, DescentFailure, DescentMatch, DescentMatchResult, DescentMatchRule } from './types.ts' */ @@ -41,18 +41,6 @@ import { definedEntries } from '../../types/object/module.f.mjs' import { emptyTagMap, isRepeat, toData } from '../data/module.f.mjs' import { leafAt, mrFail, mrSuccess, physicalIdx, symbolAt } from '../matcher/module.f.mjs' - - -/** - * The machine's own result: a {@link DescentMatchResult} positioned by the - * complete cursor, and with no failure record — that one is tracked per match - * rather than per frame. This backend always has a position, so it needs no - * `null` case. - * - * @template T - * @typedef {AstResult, Cursor>} _Result - */ - /** * A leaf here is a code point with its metadata, so its symbol is the first * half. This is the only thing {@link symbolAt} needs to know about a leaf. diff --git a/fjs/bnf/descent/private.ts b/fjs/bnf/descent/private.ts index 452fe8e6f..6a3cd569b 100644 --- a/fjs/bnf/descent/private.ts +++ b/fjs/bnf/descent/private.ts @@ -1,5 +1,6 @@ -import type { Cursor } from "../matcher/types.ts" +import type { AstResult, Cursor } from "../matcher/types.ts" import type { TerminalRange } from "../types.ts" +import type { CodePointMeta } from "./types.ts" /** * The furthest-failure record while matching, positioned by the complete @@ -10,3 +11,11 @@ export type _Failure = { readonly pos: Cursor readonly expected: readonly TerminalRange[] } + +/** + * The machine's own result: a {@link DescentMatchResult} positioned by the + * complete cursor, and with no failure record — that one is tracked per match + * rather than per frame. This backend always has a position, so it needs no + * `null` case. + */ +export type _Result = AstResult, Cursor> From 21c444e0ea4a78f711857bef82bd942673d86d56 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 11:10:38 -0700 Subject: [PATCH 072/370] todo: keep dependency diagram intra-module --- fjs/todo/separate-private-types.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 1bc19bdcb..8110d887f 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -56,14 +56,17 @@ Do not create it mechanically for every `_` name. #### Dependency order -Preserve the dependency direction for the roles that exist: +Within one module directory, preserve the dependency direction for the roles that +exist: ```text -meta/module.f.mjs <- types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs +types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs ``` The arrow points from dependency to dependent. This is a layering guide, not a -requirement that every file or edge exists. +requirement that every file or edge exists. A subordinate module such as +`meta/module.f.mjs` is a separate module and is therefore described separately +below rather than appearing in this intra-directory diagram. Move verification downstream before moving implementation upstream. For example, `fjs/effects/types.ts` currently imports implementation functions only to assert @@ -109,6 +112,10 @@ appears in a type proof. Ordinary implementation functions stay in `module.f.mjs`; recursively annotated metadata may also stay there when moving it would reverse the dependency direction. +The parent module may depend on `meta/module.f.mjs` like any other lower-level +module. The `meta/` module itself follows the same normal module conventions and, +if it grows additional files, its own intra-directory dependency order. + A private constant exported from `meta/module.f.mjs` for sibling-module linkage uses a leading `_`: @@ -211,8 +218,8 @@ type-only and use named `import type { ... }` imports. helpers required by public declarations. - [ ] Use `private.ts` only where separating implementation-private file-scope types improves the design. -- [ ] Preserve the dependency direction shown above; move verification downstream - when that is cleaner. +- [ ] Preserve the intra-directory dependency direction shown above; move + verification downstream when that is cleaner. - [ ] Move the `fjs/effects/types.ts` implementation-signature asserts into proof functions in `fjs/effects/proof.f.mjs`. - [ ] Review recursive cases individually, including `fjs/media/revision` and @@ -244,8 +251,8 @@ type-only and use named `import type { ... }` imports. required companion. - A subordinate module such as `meta/module.f.mjs`, when present, is an optional metaprogramming/design tool rather than a special file role or requirement. -- The dependency direction is preserved; assertions do not create reverse edges - merely for convenience. +- The intra-directory dependency direction is preserved; assertions do not create + reverse edges merely for convenience. - Private types/constants use leading `_`, even when linkage requires an export. - Existing `module.f.mjs` discovery and coverage rules automatically include `meta/module.f.mjs`; no metadata-specific coverage convention exists. From 6ea78aac842473c524e6aff086b0c4a1ec91c1ed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 18:23:02 +0000 Subject: [PATCH 073/370] todo: settle the imports/promises/realms study with measurements The file asked three questions and marked itself not actionable. Two are now answered with evidence, and the third -- the decision -- is all that is left. What the two runners do was measured across seven values rather than argued from the code, and two beliefs turned out to be wrong. The cross-realm exposure is worse than "a rejected promise reads as a pass": a cross-realm promise that *resolves* is walked as an ordinary object, and a promise has no enumerable own keys, so every test inside it silently disappears -- `fjs t` reported 6 tests where 7 existed. And the browser's 150 lines buy less than assumed: the two runners differ on three rows of seven, and the `Symbol.toStringTag` spoofs everyone worries about are not among them, because `instanceof` already refuses those in both. The brand check this file guessed at works. `v instanceof Promise || v.constructor?.resolve?.(v) === v` gets six of seven, missing only a deliberately forged `constructor.resolve` -- which nobody reaches by accident, and a proof named `then` has `Object` for a constructor, so the rule this file exists to protect still holds. Prototyped in `effects/node`'s `sandbox` and reverted: the rejected cross-realm promise becomes a failure, the resolved one's subtree is discovered and its failing child reported, the spoof and hostile-species rows are unchanged, and the suite stays 3477/3477 at 100% coverage. What it cannot buy is the configurable hostile-species case, where the failure happens after the check, inside `then`. Keeping that means keeping essentially all 150 lines for one row. Recommends adopting the check and dropping the machinery, with that row recorded as knowingly given up -- and says plainly that the other answer is available. No code changes; this is the evidence for a decision, not the decision. --- .../todo/imports-promises-realms.md | 119 ++++++++++++++++-- .../todo/share-browser-console-runner.md | 11 +- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 0cfe8f249..21c4e14f8 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -1,7 +1,7 @@ ## Investigate imports, promises and realms **Priority:** P3 -**Status:** open — investigation, not yet actionable +**Status:** open — investigated; a decision is now the only thing missing ### Problem @@ -50,18 +50,121 @@ that is the point at which cross-realm promises stop being hypothetical. - **State the layering.** One document saying which layer adopts a `then` and which layer refuses to, and why both are right. Until that exists, every fix to one looks like a bug in the other. -- **Find a brand check that survives a realm and cannot be forged.** - `Object.prototype.toString` is forgeable through `Symbol.toStringTag`. - `Promise.resolve(p) === p` against the value's own constructor is a candidate. +- [x] **Find a brand check that survives a realm and cannot be forged.** Done — + see Findings. `Promise.resolve(p) === p` against the value's own constructor + works, combined with `instanceof`; it is forgeable only deliberately. Whatever is chosen must be one function every interpreter calls. - **Decide whether the runner should see namespace objects at all.** If discovery handed the runner a plain record of proofs rather than the module namespace, the `then` export hazard would not reach it — and the `then`-export ban could become a check rather than a convention. -- **Establish what the 150 lines actually buy**, from the proofs that cover them - (`../browser/species.proof.mjs`), so that whatever replaces them is measured - against the same cases rather than against a memory — and so that removing - them, if that is the answer, is a decision with a list attached. +- [x] **Establish what the 150 lines actually buy.** Done — see Findings. Three + rows of seven, and one of those three is unreachable by a brand check. + +### Findings + +The study below was run against `438dd85`. Everything in it is measured, and the +scripts are trivial to re-run; nothing here is inferred from reading the code. + +#### What the two runners actually do today + +Seven values, put through both runners. `want` is what the runner's own stated +rules say should happen. + +| value | want | `fjs t` today | browser today | +| --- | --- | --- | --- | +| same-realm promise | await | await | await | +| **cross-realm promise, rejected** | fail | **reported as a pass** | fails | +| **cross-realm promise resolving to a tree** | walk it | **subtree never discovered** | walked | +| plain `{ then }` proof tree | tree | tree | tree | +| `Symbol.toStringTag: 'Promise'` spoof | tree | tree | tree | +| frozen spoof | tree | tree | tree | +| hostile species, `constructor` pinned | fail | fails (`species`) | fails (`species`) | +| hostile species, `constructor` configurable | — | fails (`species`) | **shadows, recovers, runs the subtree** | + +Two things this corrects about the story we had been telling: + +- **The exposure is worse than "a rejected promise reads as a pass."** A + cross-realm promise that *resolves* is walked as an ordinary object, and a + promise has no enumerable own keys — so every test inside it silently + disappears. In the fixture, `fjs t` reported 6 tests where 7 exist. A false + pass is visible in a total; a test that was never counted is not. +- **The 150 lines buy less than assumed.** Of the seven cases, the browser and + `fjs t` differ on exactly three: the two cross-realm rows, and the + configurable hostile-species row. The spoof defences everyone worries about + are not a difference at all — `instanceof Promise` already refuses a + `Symbol.toStringTag` spoof, in both runners. + +#### A brand check that survives a realm + +The candidate this file named turns out to work, in combination with the check +that is already there: + +```js +const isPromise = v => { + if (v instanceof Promise) { return true } + try { + const c = v?.constructor + return typeof c?.resolve === 'function' && c.resolve(v) === v + } catch { return false } +} +``` + +`Promise.resolve` returns its argument unchanged when the argument is a promise +whose `constructor` is the receiver — a native identity that holds in the +promise's *own* realm, which is the thing `instanceof` cannot reach across. + +| value | `instanceof` | `toStringTag` | `instanceof \|\| ctor.resolve` | +| --- | --- | --- | --- | +| same-realm promise | ✅ | ✅ | ✅ | +| cross-realm promise | ❌ | ✅ | ✅ | +| plain `{ then }` tree | ✅ | ✅ | ✅ | +| tagged spoof | ✅ | ❌ | ✅ | +| frozen tagged spoof | ✅ | ❌ | ✅ | +| hostile-species promise | ✅ | ✅ | ✅ | +| deliberately forged `constructor.resolve` | ✅ | ✅ | ❌ | + +Six of seven, against `instanceof`'s six and `toStringTag`'s five — and the one +it misses is the one nobody reaches by accident. A proof named `then` has +`Object` for a constructor and `Object.resolve` does not exist, so the rule this +file exists to protect — an object carrying a `then` proof stays a proof tree — +holds. Forging `constructor.resolve` to return its own receiver is not something +a test author does by mistake, and proofs are this repository's own code rather +than adversarial input. + +**Measured in place.** Prototyped in `effects/node/module.mjs`'s `sandbox` and +`awaitPromise` and reverted: the rejected cross-realm promise becomes a failure, +the resolved one's subtree is discovered and its failing child reported (6 tests +→ 7), the spoof and hostile-species rows are unchanged, and the full suite stays +3477/3477 at 100% coverage. So it is three lines, it fixes a real `fjs t` bug, +and it costs nothing that is currently working. + +#### What it does not buy + +The configurable hostile-species case — a genuine promise whose `constructor` +has been replaced by one whose `Symbol.species` getter throws, where the browser +today shadows `constructor` with the intrinsic `Promise` for the length of one +subscription and thereby still runs the subtree. A brand check cannot recover +that, because the failure happens *after* the check, inside `then`. Keeping it +means keeping `subscribe`, `speciesFails` and the shadow — roughly the whole 150 +lines — for one row of the table. + +### Recommendation + +Adopt the combined check in the shared `sandbox`, and drop the species +machinery, recording the configurable hostile-species case as knowingly given +up. That is one rule, stated in three lines, that both runners can hold; it +closes an exposure `fjs t` has today; and it leaves the browser worse off in +exactly one exotic case rather than in the three the naive port would have. + +The alternative — keep the machinery and make `fjs t` adopt it — is available +and is not obviously wrong, but it is 150 lines of `constructor` shadowing in +the path that executes every proof body in both hosts, to defend a case that has +never been observed outside a proof written to construct it. + +**This is the decision that unblocks step 3 of +[share the browser and console proof runners](share-browser-console-runner.md).** +Either answer unblocks it; what must not happen is a port choosing by accident. ### Constraints diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 47e29f370..e2eb8925e 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -122,10 +122,13 @@ and is reviewable without the next one. they currently do not. **This step is blocked on a decision, not on work**: the browser carries ~150 lines of `Symbol.species` machinery that `fjs t` has no equivalent for, so merging the two answers the cross-realm - question in [imports, promises and realms](imports-promises-realms.md) — - which that file marks as investigation. Settle it there first. Doing it - inside a port is how the last attempt lost a defence nobody chose to - lose. + question in [imports, promises and realms](imports-promises-realms.md). + **That investigation is now done** and carries a measured recommendation: + a three-line brand check gets six of the seven cases, fixes a real `fjs t` + exposure — a cross-realm promise's whole subtree is invisible to it today + — and gives up one exotic case the 150 lines cover. What is left is the + choice, which is one sentence either way. Doing it inside a port is how + the last attempt lost a defence nobody chose to lose. - [ ] **4. Common effects.** Move the host-independent operations (`all`, `await`, `fetch`, `import`, `now`, `sandbox`) out of `effects/node` into a shared module that `effects/node` re-exports unchanged, so nothing has to From eed4715fe8958166193e62ba1f1e98743751c5de Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 11:48:18 -0700 Subject: [PATCH 074/370] AGENTS.md: reserve .mjs for effects --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 0ac48b15a..505d8b80e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,11 @@ dependency-update procedure: [CONTRIBUTING.md](./CONTRIBUTING.md). ## 3. FunctionalScript and TypeScript (`fjs/`) +Runtime code under `fjs/` is FunctionalScript by default: write it in `.f.mjs`. +Plain `.mjs` is reserved for effect implementations — common, Node, browser, or +other platform-specific effect code. Do not put ordinary pure logic in `.mjs`; +isolate the effectful boundary there and keep the rest in `.f.mjs`. + 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 From e95ee2c2b4e50bd3328708de0ece8f3fb975908e Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 11:53:09 -0700 Subject: [PATCH 075/370] AGENTS.md: preserve non-FunctionalScript proof escape hatch --- AGENTS.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 505d8b80e..544882401 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,9 +68,11 @@ dependency-update procedure: [CONTRIBUTING.md](./CONTRIBUTING.md). ## 3. FunctionalScript and TypeScript (`fjs/`) Runtime code under `fjs/` is FunctionalScript by default: write it in `.f.mjs`. -Plain `.mjs` is reserved for effect implementations — common, Node, browser, or -other platform-specific effect code. Do not put ordinary pure logic in `.mjs`; -isolate the effectful boundary there and keep the rest in `.f.mjs`. +Plain implementation `.mjs` is reserved for effect implementations — common, +Node, browser, or other platform-specific effect code. Non-FunctionalScript +proofs may use `proof.mjs` when the proof itself requires host JavaScript +behavior that FunctionalScript forbids. Do not put ordinary pure logic in +`.mjs`; isolate the effectful boundary there and keep the rest in `.f.mjs`. 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. From 93bf36bf2287647e958db9b7dee29814b0ec8288 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 11:57:12 -0700 Subject: [PATCH 076/370] AGENTS.md: keep business logic in FunctionalScript --- AGENTS.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 544882401..486a0efc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,12 +67,12 @@ dependency-update procedure: [CONTRIBUTING.md](./CONTRIBUTING.md). ## 3. FunctionalScript and TypeScript (`fjs/`) -Runtime code under `fjs/` is FunctionalScript by default: write it in `.f.mjs`. -Plain implementation `.mjs` is reserved for effect implementations — common, -Node, browser, or other platform-specific effect code. Non-FunctionalScript -proofs may use `proof.mjs` when the proof itself requires host JavaScript -behavior that FunctionalScript forbids. Do not put ordinary pure logic in -`.mjs`; isolate the effectful boundary there and keep the rest in `.f.mjs`. +Business logic under `fjs/` belongs in FunctionalScript: write it in `.f.mjs`. +Use plain `.mjs` only for code that cannot reasonably be FunctionalScript because +it performs effects or depends on host JavaScript behavior, such as effect +implementations, platform adapters, runners, test harnesses, and host-specific +proofs. Keep such `.mjs` files thin: isolate the impure or host-specific boundary +there and move business logic into `.f.mjs`. 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. From 5de179f2dcbbb08ec5b85de944ffa6af80ccd2ae Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 12:27:47 -0700 Subject: [PATCH 077/370] AGENTS.md: track existing .mjs business logic --- AGENTS.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 486a0efc9..119fabd00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,11 +68,13 @@ dependency-update procedure: [CONTRIBUTING.md](./CONTRIBUTING.md). ## 3. FunctionalScript and TypeScript (`fjs/`) Business logic under `fjs/` belongs in FunctionalScript: write it in `.f.mjs`. -Use plain `.mjs` only for code that cannot reasonably be FunctionalScript because -it performs effects or depends on host JavaScript behavior, such as effect -implementations, platform adapters, runners, test harnesses, and host-specific -proofs. Keep such `.mjs` files thin: isolate the impure or host-specific boundary -there and move business logic into `.f.mjs`. +Use plain `.mjs` only where code must perform effects or depend on host JavaScript +behavior; effect implementations, platform adapters, runners, test harnesses, +and host-specific proofs are examples, not a closed list of exceptions. Keep +such `.mjs` files thin: isolate the impure or host-specific boundary there and +move business logic into `.f.mjs`. Existing `.mjs` files that violate this rule +are migration debt, not precedent: find or file a co-located `todo/` to extract +the business logic as soon as possible. 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. From b6137789b666e077fe0a6fe63c6d6bb8b0bc856e Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 12:27:58 -0700 Subject: [PATCH 078/370] website: track browser-source FunctionalScript extraction --- .../todo/browser-source-functional-script.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 fjs/website/todo/browser-source-functional-script.md diff --git a/fjs/website/todo/browser-source-functional-script.md b/fjs/website/todo/browser-source-functional-script.md new file mode 100644 index 000000000..b4d69e55b --- /dev/null +++ b/fjs/website/todo/browser-source-functional-script.md @@ -0,0 +1,36 @@ +## Move browser source analysis to FunctionalScript + +**Priority:** P1 +**Status:** open + +### Problem + +[`browser-source.mjs`](../browser-source.mjs) contains pure source-analysis logic: +it tokenizes authored modules, detects a named `proof` export, extracts static +module specifiers, and classifies local imports. This is business logic with no +host or effect boundary, so keeping it in plain `.mjs` violates the repository +rule that business logic under `fjs/` belongs in FunctionalScript. + +The existing file is migration debt, not an exception to that rule. + +### Proposal + +Move the pure scanner and classification API to authored `.f.mjs` with normal +FunctionalScript proofs. Keep plain `.mjs` only if a genuinely host-specific +adapter remains; do not preserve an `.mjs` wrapper merely for the old filename. +Update website preparation to consume the FunctionalScript module directly. + +### Tasks + +- [ ] Move `exportsProof`, `specifiers`, `local`, and their supporting scanner + logic from `browser-source.mjs` to `.f.mjs`. +- [ ] Move the corresponding pure proofs to `.f.mjs` and preserve coverage. +- [ ] Update `browser-prepare.mjs` and other importers to use the FunctionalScript + module. +- [ ] Delete the obsolete plain `.mjs` implementation once no host-specific + boundary remains. + +### Related + +- [`browser-source.mjs`](../browser-source.mjs) +- [`emergent_testing/todo/share-browser-console-runner.md`](../../emergent_testing/todo/share-browser-console-runner.md) — existing migration plan for the separate `emergent_testing/browser.mjs` violation. From c527cc20efcd8c47e6cf57b3e950f75f4a9f1708 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 19:31:52 +0000 Subject: [PATCH 079/370] todo: correct the promise findings -- a brand check is not enough Review raised two cases against the recommendation in the first draft of this study. Both reproduce, and both hang rather than misreport: - A proof tree whose constructor has an identity-style `resolve` satisfies the proposed check without anyone forging anything. It is then awaited, its `then` proof is assimilated as a resolver, and a zero-argument `then` test never settles. The first draft dismissed this as deliberate forgery; it is neither deliberate nor forgery. - A genuine cross-realm promise with an overridden own `then` passes any brand check, because it really is a promise -- and `await` then calls the override, which a no-op never settles. No brand check can fix this: the defect is in the subscription that follows, not the classification. The intrinsic `Promise.prototype.then` is both the check and the subscription, and gets all seven cases right. Its `Reflect.apply` must sit outside a `new Promise` executor or the brand-check throw is uncatchable, which is why `subscribe` in browser.mjs is shaped the way it is. Prototyping it in `effects/node`'s `sandbox` also showed the species handling is load-bearing: treating a throw from the intrinsic `then` as "not a promise" turns a hostile-species promise from `error: species` into a silent `ok` with its subtree lost, because "not a promise" and "promise I cannot subscribe to" collapse into one answer. So the recommendation is reversed. The browser's mechanism is right and `fjs t` should adopt it; the three lines would have introduced two ways to hang the suite that gates this repository. What is left to decide is only whether to keep the `constructor` shadow-and-retry, which is the one part the "one exotic row" description ever actually applied to. --- .../todo/imports-promises-realms.md | 157 ++++++++++-------- .../todo/share-browser-console-runner.md | 12 +- 2 files changed, 91 insertions(+), 78 deletions(-) diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 21c4e14f8..2383d979a 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -89,82 +89,93 @@ Two things this corrects about the story we had been telling: promise has no enumerable own keys — so every test inside it silently disappears. In the fixture, `fjs t` reported 6 tests where 7 exist. A false pass is visible in a total; a test that was never counted is not. -- **The 150 lines buy less than assumed.** Of the seven cases, the browser and - `fjs t` differ on exactly three: the two cross-realm rows, and the - configurable hostile-species row. The spoof defences everyone worries about - are not a difference at all — `instanceof Promise` already refuses a - `Symbol.toStringTag` spoof, in both runners. - -#### A brand check that survives a realm - -The candidate this file named turns out to work, in combination with the check -that is already there: - -```js -const isPromise = v => { - if (v instanceof Promise) { return true } - try { - const c = v?.constructor - return typeof c?.resolve === 'function' && c.resolve(v) === v - } catch { return false } -} -``` - -`Promise.resolve` returns its argument unchanged when the argument is a promise -whose `constructor` is the receiver — a native identity that holds in the -promise's *own* realm, which is the thing `instanceof` cannot reach across. - -| value | `instanceof` | `toStringTag` | `instanceof \|\| ctor.resolve` | +- **The spoof defences everyone worries about are not a difference at all** — + `instanceof Promise` already refuses a `Symbol.toStringTag` spoof, in both + runners. (An earlier draft of this section went on to conclude that the + browser's machinery therefore bought little. That conclusion was wrong; see + the two sections below, which is where this study actually landed.) + +#### A brand check is not enough, and `await` is the wrong subscription + +The candidate this file named — `v instanceof Promise || v.constructor?.resolve?.(v) === v` +— classifies six of seven values correctly, and recommending it was still +wrong. Two hazards, both raised in review of the first draft of these findings +and both **reproduced**, and both ending in a **hang** rather than a wrong +result: + +- **A proof tree can be a false positive without anyone forging anything.** A + tree whose constructor has an identity-style `resolve` — `class A { static + resolve(x) { return x } }` — satisfies the check. It is then awaited, its + enumerable `then` proof is assimilated as a resolver, and a zero-argument + `then` test that ignores its arguments never settles. Measured: `HUNG`. The + first draft dismissed this as "deliberate forgery"; it is neither deliberate + nor forgery. +- **Classifying correctly is not sufficient.** A genuine cross-realm promise + with an overridden own `then` passes any brand check — it really is a promise + — and `await` then calls that override, because `await` on a promise from + another realm goes through `then` rather than adopting it directly. A no-op + override never settles. Measured: `HUNG`. + +The second is the important one: it is not about *identifying* a promise at all. +No brand check can fix it, because the defect is in the subscription that +follows. + +**The intrinsic `then` is both, and gets everything right.** What the browser +does — `Reflect.apply(Promise.prototype.then, v, [onOk, onErr])` — is a native +brand check that throws for a non-promise, *and* a subscription that ignores the +value's own `then`: + +| value | `instanceof` | `instanceof \|\| ctor.resolve` | intrinsic `then` | | --- | --- | --- | --- | | same-realm promise | ✅ | ✅ | ✅ | | cross-realm promise | ❌ | ✅ | ✅ | -| plain `{ then }` tree | ✅ | ✅ | ✅ | -| tagged spoof | ✅ | ❌ | ✅ | -| frozen tagged spoof | ✅ | ❌ | ✅ | -| hostile-species promise | ✅ | ✅ | ✅ | -| deliberately forged `constructor.resolve` | ✅ | ✅ | ❌ | - -Six of seven, against `instanceof`'s six and `toStringTag`'s five — and the one -it misses is the one nobody reaches by accident. A proof named `then` has -`Object` for a constructor and `Object.resolve` does not exist, so the rule this -file exists to protect — an object carrying a `then` proof stays a proof tree — -holds. Forging `constructor.resolve` to return its own receiver is not something -a test author does by mistake, and proofs are this repository's own code rather -than adversarial input. - -**Measured in place.** Prototyped in `effects/node/module.mjs`'s `sandbox` and -`awaitPromise` and reverted: the rejected cross-realm promise becomes a failure, -the resolved one's subtree is discovered and its failing child reported (6 tests -→ 7), the spoof and hostile-species rows are unchanged, and the full suite stays -3477/3477 at 100% coverage. So it is three lines, it fixes a real `fjs t` bug, -and it costs nothing that is currently working. - -#### What it does not buy - -The configurable hostile-species case — a genuine promise whose `constructor` -has been replaced by one whose `Symbol.species` getter throws, where the browser -today shadows `constructor` with the intrinsic `Promise` for the length of one -subscription and thereby still runs the subtree. A brand check cannot recover -that, because the failure happens *after* the check, inside `then`. Keeping it -means keeping `subscribe`, `speciesFails` and the shadow — roughly the whole 150 -lines — for one row of the table. - -### Recommendation - -Adopt the combined check in the shared `sandbox`, and drop the species -machinery, recording the configurable hostile-species case as knowingly given -up. That is one rule, stated in three lines, that both runners can hold; it -closes an exposure `fjs t` has today; and it leaves the browser worse off in -exactly one exotic case rather than in the three the naive port would have. - -The alternative — keep the machinery and make `fjs t` adopt it — is available -and is not obviously wrong, but it is 150 lines of `constructor` shadowing in -the path that executes every proof body in both hosts, to defend a case that has -never been observed outside a proof written to construct it. - -**This is the decision that unblocks step 3 of -[share the browser and console proof runners](share-browser-console-runner.md).** -Either answer unblocks it; what must not happen is a port choosing by accident. +| **cross-realm, own `then` override** | ❌ | **HANGS** | ✅ | +| plain `{ then }` proof tree | ✅ | ✅ | ✅ | +| tagged spoof | ✅ | ✅ | ✅ | +| frozen tagged spoof | ✅ | ✅ | ✅ | +| **identity-`resolve` constructor tree** | ✅ | **HANGS** | ✅ | + +One detail is not incidental: the `Reflect.apply` has to sit **outside** a `new +Promise` executor. A throw inside an executor rejects the promise instead of +propagating, so the brand check becomes uncatchable — which is exactly why +`subscribe` in `../browser.mjs` captures its `settle` first and applies +afterwards. Written the obvious way instead, the check throws out of the runner. + +#### The species handling is load-bearing too + +Prototyped in `effects/node`'s `sandbox`, treating a throw from the intrinsic +`then` as "not a promise": the cross-realm rows are fixed as expected, but the +hostile-species promise turns from `error: species` into a silent **`ok` with +its subtree lost** — because "this is not a promise" and "this is a promise I +cannot subscribe to" become the same answer. Telling those apart is what +`speciesFails`, the `Object.prototype.toString` re-check and the `constructor` +shadow in `runPromise` are for. They are not decoration. + +### Recommendation, revised + +**The browser's mechanism is right, and `fjs t` should adopt it.** That reverses +the first draft of these findings, which recommended replacing it with three +lines; the three lines would have introduced two ways to hang the suite into the +runner that gates this repository. + +So step 3 is no longer a question of *whether* to keep the machinery, but of how +much of it the shared `sandbox` needs: + +- `subscribe` — the intrinsic-`then` brand check and subscription. **Keep.** It + is the whole answer to cross-realm promises, own-`then` overrides and spoofs, + and it is about fifteen lines. +- `speciesFails` and the re-check — distinguishing "not a promise" from "promise + I cannot subscribe to". **Keep**, unless the runner is content to report a + hostile-species promise as a silent pass, which it should not be. +- The `constructor` shadow-and-retry — *recovering* a configurable + hostile-species promise so its subtree still runs. **This is the only + genuinely optional part**, and the only one the first draft's "one exotic row" + description actually applied to. Dropping it costs the `throwingSpecies` + proof; `pinnedThrowingSpecies` holds either way. + +Adopting this in `fjs t` fixes the cross-realm exposure — a rejected cross-realm +promise reported as a pass, and a resolved one's subtree never discovered — and +costs `fjs t` nothing it currently has. ### Constraints diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index e2eb8925e..ae6457bb0 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -123,11 +123,13 @@ and is reviewable without the next one. work**: the browser carries ~150 lines of `Symbol.species` machinery that `fjs t` has no equivalent for, so merging the two answers the cross-realm question in [imports, promises and realms](imports-promises-realms.md). - **That investigation is now done** and carries a measured recommendation: - a three-line brand check gets six of the seven cases, fixes a real `fjs t` - exposure — a cross-realm promise's whole subtree is invisible to it today - — and gives up one exotic case the 150 lines cover. What is left is the - choice, which is one sentence either way. Doing it inside a port is how + **That investigation is now done** and its answer is the opposite of what + was expected: the browser's mechanism is right and `fjs t` should adopt + it. A brand check cannot replace it — the intrinsic `then` is both the + check and a subscription that ignores an own `then`, and two measured + cases hang without it. What is left to choose is only whether the shared + `sandbox` keeps the `constructor` shadow-and-retry, which is the one + genuinely optional part. Doing it inside a port is how the last attempt lost a defence nobody chose to lose. - [ ] **4. Common effects.** Move the host-independent operations (`all`, `await`, `fetch`, `import`, `now`, `sandbox`) out of `effects/node` into a From e00e6213795b1fe7ffb90934e12af486a6a20bc5 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 12:37:28 -0700 Subject: [PATCH 080/370] emergent_testing: track browser.mjs business logic extraction --- .../todo/browser-mjs-business-logic.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 fjs/emergent_testing/todo/browser-mjs-business-logic.md diff --git a/fjs/emergent_testing/todo/browser-mjs-business-logic.md b/fjs/emergent_testing/todo/browser-mjs-business-logic.md new file mode 100644 index 000000000..e66f3dc68 --- /dev/null +++ b/fjs/emergent_testing/todo/browser-mjs-business-logic.md @@ -0,0 +1,43 @@ +## Extract business logic from browser.mjs + +**Priority:** P1 +**Status:** open + +### Problem + +[`browser.mjs`](../browser.mjs) is necessarily a plain JavaScript browser host +boundary, but it also contains substantial host-independent proof execution, +result, and traversal logic. Under the repository rule that business logic +belongs in `.f.mjs`, that mixed implementation is migration debt and should be +reduced as soon as possible. + +The broader runner-sharing design is already tracked in +[`share-browser-console-runner.md`](share-browser-console-runner.md). That issue +includes architectural work and decisions that need not block extracting +business logic that is already clearly host-independent. + +### Proposal + +Shrink `browser.mjs` toward a thin browser adapter. Move any business logic that +can be expressed without browser APIs into `.f.mjs`, reusing shared +`emergent_testing/module.f.mjs` logic where appropriate rather than copying it. +Leave only code that genuinely requires browser/host JavaScript behavior in +plain `.mjs`. + +Do not use this TODO to redesign proof semantics. If an extraction reaches an +open semantic decision documented by `share-browser-console-runner.md` or one +of its dependencies, leave that specific part for the existing issue and +continue with the unblocked extraction. + +### Tasks + +- [ ] Identify host-independent logic still implemented in `browser.mjs`. +- [ ] Move each unblocked piece to `.f.mjs` with FunctionalScript proof coverage. +- [ ] Reuse shared emergent-testing logic instead of preserving duplicate browser copies. +- [ ] Keep browser APIs, DOM integration, module loading, and other genuinely host-specific code in the thin `.mjs` boundary. +- [ ] Delete this TODO once `browser.mjs` contains no business logic; the broader runner-sharing TODO may remain open for its other goals. + +### Related + +- [`browser.mjs`](../browser.mjs) +- [`share-browser-console-runner.md`](share-browser-console-runner.md) — broader runner-sharing design and blocked semantic decisions. From 3738a1210badef7ebe87493a0a6b8f8349e3e4a9 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 12:42:36 -0700 Subject: [PATCH 081/370] todo: keep browser extraction in shared runner sequence --- .../todo/browser-mjs-business-logic.md | 43 ------------------- 1 file changed, 43 deletions(-) delete mode 100644 fjs/emergent_testing/todo/browser-mjs-business-logic.md diff --git a/fjs/emergent_testing/todo/browser-mjs-business-logic.md b/fjs/emergent_testing/todo/browser-mjs-business-logic.md deleted file mode 100644 index e66f3dc68..000000000 --- a/fjs/emergent_testing/todo/browser-mjs-business-logic.md +++ /dev/null @@ -1,43 +0,0 @@ -## Extract business logic from browser.mjs - -**Priority:** P1 -**Status:** open - -### Problem - -[`browser.mjs`](../browser.mjs) is necessarily a plain JavaScript browser host -boundary, but it also contains substantial host-independent proof execution, -result, and traversal logic. Under the repository rule that business logic -belongs in `.f.mjs`, that mixed implementation is migration debt and should be -reduced as soon as possible. - -The broader runner-sharing design is already tracked in -[`share-browser-console-runner.md`](share-browser-console-runner.md). That issue -includes architectural work and decisions that need not block extracting -business logic that is already clearly host-independent. - -### Proposal - -Shrink `browser.mjs` toward a thin browser adapter. Move any business logic that -can be expressed without browser APIs into `.f.mjs`, reusing shared -`emergent_testing/module.f.mjs` logic where appropriate rather than copying it. -Leave only code that genuinely requires browser/host JavaScript behavior in -plain `.mjs`. - -Do not use this TODO to redesign proof semantics. If an extraction reaches an -open semantic decision documented by `share-browser-console-runner.md` or one -of its dependencies, leave that specific part for the existing issue and -continue with the unblocked extraction. - -### Tasks - -- [ ] Identify host-independent logic still implemented in `browser.mjs`. -- [ ] Move each unblocked piece to `.f.mjs` with FunctionalScript proof coverage. -- [ ] Reuse shared emergent-testing logic instead of preserving duplicate browser copies. -- [ ] Keep browser APIs, DOM integration, module loading, and other genuinely host-specific code in the thin `.mjs` boundary. -- [ ] Delete this TODO once `browser.mjs` contains no business logic; the broader runner-sharing TODO may remain open for its other goals. - -### Related - -- [`browser.mjs`](../browser.mjs) -- [`share-browser-console-runner.md`](share-browser-console-runner.md) — broader runner-sharing design and blocked semantic decisions. From 3e41a1bb5cb6c7995dfb7151beeb57353790c797 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 12:42:44 -0700 Subject: [PATCH 082/370] todo: use high priority for browser source migration --- fjs/website/todo/browser-source-functional-script.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fjs/website/todo/browser-source-functional-script.md b/fjs/website/todo/browser-source-functional-script.md index b4d69e55b..f93faddd6 100644 --- a/fjs/website/todo/browser-source-functional-script.md +++ b/fjs/website/todo/browser-source-functional-script.md @@ -1,6 +1,6 @@ ## Move browser source analysis to FunctionalScript -**Priority:** P1 +**Priority:** P2 **Status:** open ### Problem @@ -33,4 +33,4 @@ Update website preparation to consume the FunctionalScript module directly. ### Related - [`browser-source.mjs`](../browser-source.mjs) -- [`emergent_testing/todo/share-browser-console-runner.md`](../../emergent_testing/todo/share-browser-console-runner.md) — existing migration plan for the separate `emergent_testing/browser.mjs` violation. +- [`emergent_testing/todo/share-browser-console-runner.md`](../../emergent_testing/todo/share-browser-console-runner.md) — existing ordered migration plan for the separate `emergent_testing/browser.mjs` violation. From b13fd891f1ce980f777e43c59674992f330e1424 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 19:50:50 +0000 Subject: [PATCH 083/370] todo: ask who the promise machinery is for, and answer it The study kept refining *how* to detect a promise without asking which proofs produce one. Counted: FunctionalScript has no promises, and across 125 pure `.f.mjs` proof modules there are zero leaves that return one. All 39 that exist are `async` functions in five impure `.mjs` proofs -- 32 in the browser proofs, 5 in memory, 2 in species -- every one written in this repository, in this repository's realm. That settles the realm question by counting rather than by argument. A cross-realm promise can only reach the runner if one of our own impure proofs deliberately builds one with `node:vm`, an iframe or a worker, and the only proofs that do are the ones testing the cross-realm machinery. The defence exists to defend against its own fixtures. So the recommendation is now `p instanceof Promise`, exactly as `fjs t` does it, with the species machinery and `species.proof.mjs` deleted. The one promise-adjacent value the language can produce is an object with a `then` key -- a proof named `then` -- and `instanceof` refuses it correctly, which is what `thenIsATestName` asserts. Both earlier drafts were wrong for the same reason: neither asked who the machinery was for. The first would have introduced two ways to hang the suite; the second traded 150 lines for a threat model that does not exist here. The intrinsic-`then` material is kept as the answer for the day proofs actually run in iframes or workers. --- .../todo/imports-promises-realms.md | 78 +++++++++++++------ .../todo/share-browser-console-runner.md | 14 ++-- 2 files changed, 60 insertions(+), 32 deletions(-) diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 2383d979a..2fc9f114a 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -151,31 +151,59 @@ cannot subscribe to" become the same answer. Telling those apart is what `speciesFails`, the `Object.prototype.toString` re-check and the `constructor` shadow in `runPromise` are for. They are not decoration. -### Recommendation, revised - -**The browser's mechanism is right, and `fjs t` should adopt it.** That reverses -the first draft of these findings, which recommended replacing it with three -lines; the three lines would have introduced two ways to hang the suite into the -runner that gates this repository. - -So step 3 is no longer a question of *whether* to keep the machinery, but of how -much of it the shared `sandbox` needs: - -- `subscribe` — the intrinsic-`then` brand check and subscription. **Keep.** It - is the whole answer to cross-realm promises, own-`then` overrides and spoofs, - and it is about fifteen lines. -- `speciesFails` and the re-check — distinguishing "not a promise" from "promise - I cannot subscribe to". **Keep**, unless the runner is content to report a - hostile-species promise as a silent pass, which it should not be. -- The `constructor` shadow-and-retry — *recovering* a configurable - hostile-species promise so its subtree still runs. **This is the only - genuinely optional part**, and the only one the first draft's "one exotic row" - description actually applied to. Dropping it costs the `throwingSpecies` - proof; `pinnedThrowingSpecies` holds either way. - -Adopting this in `fjs t` fixes the cross-realm exposure — a rejected cross-realm -promise reported as a pass, and a resolved one's subtree never discovered — and -costs `fjs t` nothing it currently has. +### Who is this for? — the question the study should have asked first + +**FunctionalScript has no promises and cannot produce one.** A `.f.mjs` proof is +pure: no `async`, no `await`, nothing that constructs a `Promise`. So every +promise this runner has ever awaited comes from a hand-written *impure* `.mjs` +proof. Counted: + +| | pure `.f.mjs` | impure `.mjs` | +| --- | --- | --- | +| proof modules | **125** | 5 | +| leaves returning a promise (`async () =>`) | **0** | 39 | + +All 39 live in `emergent_testing/browser/proof.mjs` (32), +`effects/node/memory/proof.mjs` (5) and +`emergent_testing/browser/species.proof.mjs` (2) — every one an `async` function +written in this repository, in this repository's own realm. + +That settles the realm question, and not by argument. A cross-realm promise can +only reach the runner if one of our own impure proofs deliberately builds one +with `node:vm`, an iframe or a worker. The only proofs that do are the ones +testing the cross-realm machinery. **The defence exists to defend against its +own fixtures**, and deleting both leaves nothing uncovered. + +The one promise-adjacent value *pure* FunctionalScript can produce is an object +with a key named `then` — a proof called `then`. `p instanceof Promise` refuses +it correctly, which is what `thenIsATestName` asserts and what makes the +structural rule hold. + +### Recommendation, revised twice + +**Do it exactly as `fjs t` does: `p instanceof Promise`, await, done.** Delete +the species machinery and `species.proof.mjs` with it. + +That is not a compromise on correctness. It is correct for every value the +language can produce, and for every value any proof in this repository actually +produces. What it gives up — cross-realm promises, `Symbol.species` recovery, +spoof defences — are answers to questions that only a fixture has ever asked. + +The earlier drafts of this section were both wrong, in opposite directions and +for the same underlying reason: neither asked who the machinery was *for*. The +first proposed a three-line brand check and would have introduced two ways to +hang the suite. The second, correcting that, concluded the browser's mechanism +was right and `fjs t` should adopt it — trading 150 lines and a subtle +subscription protocol for a threat model that does not exist here. + +**If proofs ever run in iframes or workers** — which +[browser testing](browser-testing.md) contemplates and nothing does today — a +cross-realm promise becomes reachable for the first time. That is the moment to +revisit this, with a real case in hand rather than a constructed one, and the +material is preserved above: the intrinsic `Promise.prototype.then` is both the +brand check and the subscription, its `Reflect.apply` must sit outside a `new +Promise` executor, and a throw from it must not be conflated with "not a +promise". Reach for it then, not now. ### Constraints diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index ae6457bb0..d6c9ae4fa 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -123,13 +123,13 @@ and is reviewable without the next one. work**: the browser carries ~150 lines of `Symbol.species` machinery that `fjs t` has no equivalent for, so merging the two answers the cross-realm question in [imports, promises and realms](imports-promises-realms.md). - **That investigation is now done** and its answer is the opposite of what - was expected: the browser's mechanism is right and `fjs t` should adopt - it. A brand check cannot replace it — the intrinsic `then` is both the - check and a subscription that ignores an own `then`, and two measured - cases hang without it. What is left to choose is only whether the shared - `sandbox` keeps the `constructor` shadow-and-retry, which is the one - genuinely optional part. Doing it inside a port is how + **That investigation is now done, and it unblocks this step rather than + complicating it.** FunctionalScript has no promises: across 125 pure + `.f.mjs` proof modules there are zero leaves that return one, and all 39 + that exist are `async` functions in five impure `.mjs` proofs — same + realm, our own code. The browser's cross-realm machinery defends only + against its own fixtures. So this step is `p instanceof Promise`, exactly + as `fjs t` does it, and `species.proof.mjs` goes with the machinery. Doing it inside a port is how the last attempt lost a defence nobody chose to lose. - [ ] **4. Common effects.** Move the host-independent operations (`all`, `await`, `fetch`, `import`, `now`, `sandbox`) out of `effects/node` into a From a95b738d0b4ebef7ad81ccc3a59cb99aa63e8580 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 19:56:42 +0000 Subject: [PATCH 084/370] todo: the browser runs only .f.mjs, so it cannot see a promise at all `website/browser-prepare.mjs` line 16 selects on `name.endsWith('.f.mjs')`, and the generated manifest carries 137 modules, none of them anything else. Impure `.mjs` proofs are excluded by construction -- correctly: a browser has no business running Node tests, and a promise is only the first thing that would go wrong, since those proofs reach for `node:fs`, `node:vm` and a filesystem a page does not have. So the promise machinery is circular twice over. It lives in the runner that only executes `.f.mjs`; it is exercised by `species.proof.mjs` and the cross-realm proofs, which are `.mjs` and therefore never run in a browser at all -- they run under `fjs t`, in Node, against the browser runner called as a library. Machinery in the browser path, tested by fixtures that never reach the browser, guarding values the browser cannot produce. Strictly the browser needs no promise handling whatever. Keeping `instanceof` is still the right call: one expression, and it keeps the two runners' `sandbox` identical rather than "identical except the browser omits a branch". Also files `host-targeted-tests.md` at P5 -- a convention for saying which host a non-FunctionalScript test targets. Nothing is blocked and the current rule (impure proofs are Node-only, by construction) is the desired behaviour, so the issue exists to write the constraint down rather than to propose changing it. It notes the wrinkle that the browser proofs *test* browser code from Node, so "which host does this target" and "which host does this describe" are different questions. --- fjs/emergent_testing/todo/browser-testing.md | 2 + .../todo/host-targeted-tests.md | 75 +++++++++++++++++++ .../todo/imports-promises-realms.md | 30 +++++++- 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 fjs/emergent_testing/todo/host-targeted-tests.md diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index d81dc562b..d8b1286b6 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -165,5 +165,7 @@ workers, or visual regression testing. - [`.f.mjs` proof discovery and coverage](f-mjs-test-and-coverage.md) - [Shared browser/console runner core](share-browser-console-runner.md) - [Explicit browser test controls](browser-test-controls.md) +- [Host-targeted tests](host-targeted-tests.md) — the convention impure `.mjs` + proofs would need before a browser could run any of them - [authored `.f.mjs` package support](../../ci/todo/f-mjs-package-support.md) - [project roadmap](../../../todo/plan/roadmap.md) diff --git a/fjs/emergent_testing/todo/host-targeted-tests.md b/fjs/emergent_testing/todo/host-targeted-tests.md new file mode 100644 index 000000000..fea00f99e --- /dev/null +++ b/fjs/emergent_testing/todo/host-targeted-tests.md @@ -0,0 +1,75 @@ +## A convention for saying which host a non-FunctionalScript test targets + +**Priority:** P5 +**Status:** open — no demand for it yet; filed so the constraint is written down + +### Problem + +Authored FunctionalScript runs anywhere. `.f.mjs` is pure — no host objects, no +`node:` imports, no promises — so a `.f.mjs` proof means the same thing in +`fjs t`, in a browser, and in any runner added later. That is why the browser +suite can select on the extension alone (`website/browser-prepare.mjs`: +`name => name.endsWith('.f.mjs')`) and be right. + +Impure `.mjs` proofs have no such property, and there is no way to say what they +need. Today there are five, and they differ: + +| proof | what it needs | +| --- | --- | +| `effects/node/memory/proof.mjs` | Node | +| `rtti/host.proof.mjs` | Node | +| `website/browser-source.proof.mjs` | Node | +| `emergent_testing/browser/proof.mjs` | Node, though it *tests* browser code | +| `emergent_testing/browser/species.proof.mjs` | Node, likewise | + +The last two are the interesting ones: they exercise the browser runner by +calling it as a library from Node with a DOM stand-in. So "which host does this +test target" and "which host does this test *describe*" are different questions, +and a convention has to answer the first without being confused by the second. + +The current rule — impure proofs are Node-only, by construction — is correct and +costs nothing, because nobody has wanted otherwise. **This issue is not a +proposal to change that.** It exists so that the day someone does want a +browser-only impure test, the constraint is already written down rather than +rediscovered. + +### Why it is P5 + +Nothing is blocked. The browser suite runs 137 `.f.mjs` modules and excludes +impure proofs by construction; that is the desired behaviour, not a limitation +being worked around. Running Node tests in a browser is not a goal — a promise +would be the least of what goes wrong, since a Node proof reaches for `node:fs`, +`node:vm`, `process` and a filesystem that a page does not have. + +The cost of *not* doing this is small and known: an impure test that could run in +a browser does not, and nobody notices, because none exists. + +### Preliminary design + +Unexplored on purpose. Things a design would have to settle: + +- **Where the declaration lives.** A filename convention (`proof.node.mjs`, + `proof.browser.mjs`) is discoverable without executing anything, which is what + the browser's static selection needs. An export (`export const hosts = […]`) + is more expressive and requires importing the module to read it — which the + preparation program deliberately does not do. +- **What the vocabulary is.** `node` and `browser` are the two that exist. A + list is probably better than a single value, and "runs anywhere" already has a + spelling: `.f.mjs`. +- **What a runner does with a test it cannot host.** Skipping silently is the + behaviour that hides a suite quietly losing coverage — see the proof-count + floor in [browser testing](browser-testing.md). Reporting it as skipped, with + a reason, is the honest form. +- **Whether the graph still has to be checked.** A test declaring `browser` and + importing `node:fs` is a lie the preparation program should catch, which is + the dependency-graph acceptance + [browser testing](browser-testing.md) already specifies. + +### Related + +- [Run FunctionalScript proofs inside real browsers](browser-testing.md) — the + selection and dependency-graph rules this would extend. +- [Imports, promises and realms](imports-promises-realms.md) — why the + `.f.mjs`-only rule makes the browser's promise machinery unnecessary, and what + changes if that rule is ever relaxed. +- [`.f.mjs` proof discovery and coverage](f-mjs-test-and-coverage.md) diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 2fc9f114a..7d72547d8 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -174,6 +174,22 @@ with `node:vm`, an iframe or a worker. The only proofs that do are the ones testing the cross-realm machinery. **The defence exists to defend against its own fixtures**, and deleting both leaves nothing uncovered. +**In the browser it is stronger than that: a promise cannot occur at all.** The +browser suite selects only authored FunctionalScript — +`website/browser-prepare.mjs` line 16 is `name => name.endsWith('.f.mjs')`, and +the generated manifest carries 137 modules, none of them anything else. Impure +`.mjs` proofs are excluded by construction, and rightly so: a browser has no +business running Node tests, and a promise is only the first thing that would go +wrong. So every leaf the browser runner executes is pure FunctionalScript, and +pure FunctionalScript has no promises. + +Which means the machinery is circular twice over. It lives in the runner that +*only* executes `.f.mjs`; it is exercised by `species.proof.mjs` and the +cross-realm proofs, which are `.mjs` and therefore **never run in a browser at +all** — they run under `fjs t`, in Node, against the browser runner called as a +library. Machinery in the browser path, tested by fixtures that never reach the +browser, guarding values the browser cannot produce. + The one promise-adjacent value *pure* FunctionalScript can produce is an object with a key named `then` — a proof called `then`. `p instanceof Promise` refuses it correctly, which is what `thenIsATestName` asserts and what makes the @@ -184,6 +200,11 @@ structural rule hold. **Do it exactly as `fjs t` does: `p instanceof Promise`, await, done.** Delete the species machinery and `species.proof.mjs` with it. +Strictly, the browser needs no promise handling whatever — it runs only +`.f.mjs`. Keeping `instanceof` there anyway is the cheap and honest choice: it +is one expression, it keeps the two runners' `sandbox` identical rather than +"identical except the browser omits a branch", and it costs nothing to carry. + That is not a compromise on correctness. It is correct for every value the language can produce, and for every value any proof in this repository actually produces. What it gives up — cross-realm promises, `Symbol.species` recovery, @@ -196,9 +217,12 @@ hang the suite. The second, correcting that, concluded the browser's mechanism was right and `fjs t` should adopt it — trading 150 lines and a subtle subscription protocol for a threat model that does not exist here. -**If proofs ever run in iframes or workers** — which -[browser testing](browser-testing.md) contemplates and nothing does today — a -cross-realm promise becomes reachable for the first time. That is the moment to +**If the browser suite ever runs impure `.mjs` proofs** — which needs a +convention for saying which host a non-FunctionalScript test targets, filed as +[host-targeted tests](host-targeted-tests.md) — or **if proofs ever run in +iframes or workers**, which [browser testing](browser-testing.md) contemplates +and nothing does today, then a promise, and eventually a cross-realm one, +becomes reachable for the first time. That is the moment to revisit this, with a real case in hand rather than a constructed one, and the material is preserved above: the intrinsic `Promise.prototype.then` is both the brand check and the subscription, its `Reflect.apply` must sit outside a `new From fc754ebd280576680ea6c532d18c51df2ea6cd09 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 19:58:28 +0000 Subject: [PATCH 085/370] todo: record the .f.mjs-only rule as the answer, not as a gap The first version of `host-targeted-tests.md` had a "preliminary design" section that read as a plan -- filename conventions, a vocabulary, skip reporting -- which invites exactly the work nobody asked for. Rewritten as a decision. Loading JavaScript written against Node into a browser and expecting it to test anything is a nightmare: a Node proof reaches for `node:fs`, `node:vm`, `process`, `node:test`, a filesystem and a subprocess, and no labelling convention changes that. The promise question that led here is the smallest visible corner of it. The file stays because the reasoning is worth keeping -- the `.f.mjs`-only rule looks like an omission if met without context, and someone will otherwise decide it needs fixing. It now says there is no work item, and keeps only the two things a design would have to face if a concrete need ever appears. --- fjs/emergent_testing/todo/browser-testing.md | 4 +- .../todo/host-targeted-tests.md | 112 +++++++----------- .../todo/imports-promises-realms.md | 13 +- 3 files changed, 52 insertions(+), 77 deletions(-) diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index d8b1286b6..929be54bf 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -165,7 +165,7 @@ workers, or visual regression testing. - [`.f.mjs` proof discovery and coverage](f-mjs-test-and-coverage.md) - [Shared browser/console runner core](share-browser-console-runner.md) - [Explicit browser test controls](browser-test-controls.md) -- [Host-targeted tests](host-targeted-tests.md) — the convention impure `.mjs` - proofs would need before a browser could run any of them +- [Impure `.mjs` proofs are Node-only](host-targeted-tests.md) — why the + `.f.mjs`-only selection rule is the answer rather than a gap - [authored `.f.mjs` package support](../../ci/todo/f-mjs-package-support.md) - [project roadmap](../../../todo/plan/roadmap.md) diff --git a/fjs/emergent_testing/todo/host-targeted-tests.md b/fjs/emergent_testing/todo/host-targeted-tests.md index fea00f99e..166f1a56e 100644 --- a/fjs/emergent_testing/todo/host-targeted-tests.md +++ b/fjs/emergent_testing/todo/host-targeted-tests.md @@ -1,75 +1,51 @@ -## A convention for saying which host a non-FunctionalScript test targets +## Impure `.mjs` proofs are Node-only, and that is the answer **Priority:** P5 -**Status:** open — no demand for it yet; filed so the constraint is written down - -### Problem - -Authored FunctionalScript runs anywhere. `.f.mjs` is pure — no host objects, no -`node:` imports, no promises — so a `.f.mjs` proof means the same thing in -`fjs t`, in a browser, and in any runner added later. That is why the browser -suite can select on the extension alone (`website/browser-prepare.mjs`: -`name => name.endsWith('.f.mjs')`) and be right. - -Impure `.mjs` proofs have no such property, and there is no way to say what they -need. Today there are five, and they differ: - -| proof | what it needs | -| --- | --- | -| `effects/node/memory/proof.mjs` | Node | -| `rtti/host.proof.mjs` | Node | -| `website/browser-source.proof.mjs` | Node | -| `emergent_testing/browser/proof.mjs` | Node, though it *tests* browser code | -| `emergent_testing/browser/species.proof.mjs` | Node, likewise | - -The last two are the interesting ones: they exercise the browser runner by -calling it as a library from Node with a DOM stand-in. So "which host does this -test target" and "which host does this test *describe*" are different questions, -and a convention has to answer the first without being confused by the second. - -The current rule — impure proofs are Node-only, by construction — is correct and -costs nothing, because nobody has wanted otherwise. **This issue is not a -proposal to change that.** It exists so that the day someone does want a -browser-only impure test, the constraint is already written down rather than -rediscovered. - -### Why it is P5 - -Nothing is blocked. The browser suite runs 137 `.f.mjs` modules and excludes -impure proofs by construction; that is the desired behaviour, not a limitation -being worked around. Running Node tests in a browser is not a goal — a promise -would be the least of what goes wrong, since a Node proof reaches for `node:fs`, -`node:vm`, `process` and a filesystem that a page does not have. - -The cost of *not* doing this is small and known: an impure test that could run in -a browser does not, and nobody notices, because none exists. - -### Preliminary design - -Unexplored on purpose. Things a design would have to settle: - -- **Where the declaration lives.** A filename convention (`proof.node.mjs`, - `proof.browser.mjs`) is discoverable without executing anything, which is what - the browser's static selection needs. An export (`export const hosts = […]`) - is more expressive and requires importing the module to read it — which the - preparation program deliberately does not do. -- **What the vocabulary is.** `node` and `browser` are the two that exist. A - list is probably better than a single value, and "runs anywhere" already has a - spelling: `.f.mjs`. -- **What a runner does with a test it cannot host.** Skipping silently is the - behaviour that hides a suite quietly losing coverage — see the proof-count - floor in [browser testing](browser-testing.md). Reporting it as skipped, with - a reason, is the honest form. -- **Whether the graph still has to be checked.** A test declaring `browser` and - importing `node:fs` is a lie the preparation program should catch, which is - the dependency-graph acceptance +**Status:** not planned — recorded so it is not rediscovered as a gap + +### The decision + +**The browser runs authored FunctionalScript and nothing else.** +`website/browser-prepare.mjs` selects on `name.endsWith('.f.mjs')`, the generated +manifest carries 137 such modules, and impure `.mjs` proofs are excluded by +construction. That is correct behaviour, not a limitation. + +Loading JavaScript written against Node into a browser and expecting it to test +anything is a nightmare, and nobody has asked for it. A Node proof reaches for +`node:fs`, `node:vm`, `process`, `node:test`, a filesystem and a subprocess — a +page has none of them, and no convention for labelling tests changes that. The +promise question that led here is the smallest visible corner of it. + +**So there is no work item here.** This file exists because the reasoning is +worth keeping: the `.f.mjs`-only rule looks like an omission if you meet it +without context, and someone will otherwise decide it needs fixing. + +### Why `.f.mjs` needs no convention + +Authored FunctionalScript is pure — no host objects, no `node:` imports, no +promises — so a `.f.mjs` proof means the same thing in `fjs t`, in a browser, +and in any runner added later. The extension *is* the declaration. That is what +lets the browser select statically, without importing anything, and be right. + +### If it ever comes up + +Only if someone has a concrete impure test they want a browser to run, and can +say why it cannot be written as `.f.mjs`. Two things a design would then have to +face, both easy to miss: + +- **Targeting and describing are different questions.** + `emergent_testing/browser/proof.mjs` and `species.proof.mjs` *test* browser + code but *run* in Node, against the browser runner called as a library with a + DOM stand-in. A filename convention that conflates the two would mislabel + exactly those files. +- **A declaration is a claim, and claims need checking.** A test declaring + `browser` while importing `node:fs` is a lie the preparation program has to + catch — the dependency-graph acceptance [browser testing](browser-testing.md) already specifies. ### Related - [Run FunctionalScript proofs inside real browsers](browser-testing.md) — the - selection and dependency-graph rules this would extend. -- [Imports, promises and realms](imports-promises-realms.md) — why the - `.f.mjs`-only rule makes the browser's promise machinery unnecessary, and what - changes if that rule is ever relaxed. -- [`.f.mjs` proof discovery and coverage](f-mjs-test-and-coverage.md) + `.f.mjs` selection rule this records the reasoning for. +- [Imports, promises and realms](imports-promises-realms.md) — why that rule + makes the browser's promise machinery unnecessary. diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 7d72547d8..97242a0fc 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -175,7 +175,7 @@ testing the cross-realm machinery. **The defence exists to defend against its own fixtures**, and deleting both leaves nothing uncovered. **In the browser it is stronger than that: a promise cannot occur at all.** The -browser suite selects only authored FunctionalScript — +browser suite runs authored FunctionalScript and nothing else — `website/browser-prepare.mjs` line 16 is `name => name.endsWith('.f.mjs')`, and the generated manifest carries 137 modules, none of them anything else. Impure `.mjs` proofs are excluded by construction, and rightly so: a browser has no @@ -217,12 +217,11 @@ hang the suite. The second, correcting that, concluded the browser's mechanism was right and `fjs t` should adopt it — trading 150 lines and a subtle subscription protocol for a threat model that does not exist here. -**If the browser suite ever runs impure `.mjs` proofs** — which needs a -convention for saying which host a non-FunctionalScript test targets, filed as -[host-targeted tests](host-targeted-tests.md) — or **if proofs ever run in -iframes or workers**, which [browser testing](browser-testing.md) contemplates -and nothing does today, then a promise, and eventually a cross-realm one, -becomes reachable for the first time. That is the moment to +**If proofs ever run in iframes or workers** — which +[browser testing](browser-testing.md) contemplates and nothing does today — a +cross-realm promise becomes reachable for the first time. (Running impure `.mjs` +proofs in a browser is the other way it could happen, and is +[not planned](host-targeted-tests.md).) That is the moment to revisit this, with a real case in hand rather than a constructed one, and the material is preserved above: the intrinsic `Promise.prototype.then` is both the brand check and the subscription, its `Reflect.apply` must sit outside a `new From 618c1dfd30529de71aa174d2f2250d4f7d3c77f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:08:51 +0000 Subject: [PATCH 086/370] emergent_testing: the browser decides promises the way `fjs t` does Step 3 of `todo/share-browser-console-runner.md`, which turned out to be a deletion once the scope was written down. The browser suite runs authored `.f.mjs` and nothing else -- `website/browser-prepare.mjs` selects on the extension, and the generated manifest carries 137 such modules and no others. FunctionalScript has no promises, so nothing the browser executes can be one. The `Symbol.species` machinery therefore defended values the browser cannot produce, and was exercised by fixtures that are themselves `.mjs` and so never run in a browser at all: they run under `fjs t`, in Node, against this module called as a library. `subscribe`, `speciesFails`, `runPromise` and `species.proof.mjs` are gone, replaced by `value instanceof Promise`, which is what `fjs t` decides with. The tuple wrapper stays: it is what stops promise resolution assimilating a proof tree that carries a `then` key, and `thenIsATestName`, `spoofedPromiseTag` and `frozenPromiseTag` still pin that in both runners. `crossRealmPromise` becomes `crossRealmPromiseIsWalkedAsATree`. The gap it used to cover is real and now shared with `fjs t`, so the proof pins the two agreeing rather than the browser defending alone; the measurements behind accepting it are in `todo/imports-promises-realms.md`. Scope is now stated in `todo/browser-testing.md` rather than left implicit in a selector: `.f.mjs` only, why the two kinds of module differ, and the two consequences that are easy to get wrong -- that the runner needs no promise handling of its own, and that the impure proofs driving it are not part of the suite. `fjs t` 3474/3474, `npx tsc` clean, coverage 100% lines, branches and functions. Changelog: - **BREAKING CHANGES:** `emergent_testing`: the browser runner awaits only `instanceof Promise` values, as `fjs t` does. A promise from another realm is walked as a proof tree rather than awaited; authored FunctionalScript cannot produce one, and the browser suite runs authored FunctionalScript only --- fjs/emergent_testing/browser.mjs | 138 ++---------------- fjs/emergent_testing/browser/proof.mjs | 39 ++--- .../browser/species.proof.mjs | 45 ------ fjs/emergent_testing/todo/browser-testing.md | 28 ++++ .../todo/imports-promises-realms.md | 19 ++- .../todo/share-browser-console-runner.md | 28 ++-- 6 files changed, 79 insertions(+), 218 deletions(-) delete mode 100644 fjs/emergent_testing/browser/species.proof.mjs diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index a40f5445a..3a3c02b17 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -71,126 +71,8 @@ const errorDetails = error => { * * @typedef {TestResult & { readonly message?: string, readonly stack?: string }} _BrowserTestResult */ -/** @typedef {{ readonly status: string, readonly browser: string, readonly totals: { readonly tests: number, readonly passed: number, readonly failed: number }, readonly duration: number, readonly results: readonly _BrowserTestResult[] }} BrowserTestReport */ - -/** - * Attaches the handlers with the intrinsic `then`, but answers with a promise - * of this realm instead of the one `then` returns. That result is built by - * `constructor[Symbol.species]`, which a promise can make an ordinary object: - * awaiting it would end the test before the promise it came from ever settled - * and put the species object itself in the report. - * - * The `then` call still throws — before either handler is attached — for a - * value that is not a promise or whose species construction fails, which is - * what `runPromise` reads. - * - * @type {(value: unknown, fulfilled: (value: unknown) => Promise | readonly _BrowserTestResult[], rejected: (error: unknown) => readonly _BrowserTestResult[]) => Promise} - */ -const subscribe = (value, fulfilled, rejected) => { - /** @type {(results: Promise | readonly _BrowserTestResult[]) => void} */ - let settle = () => undefined - /** @type {Promise} */ - const settled = new Promise(resolve => { settle = resolve }) - Reflect.apply(Promise.prototype.then, value, [ - /** @type {(value: unknown) => void} */ (resolved => settle(fulfilled(resolved))), - /** @type {(error: unknown) => void} */ (error => settle(rejected(error))), - ]) - return settled -} - -/** - * Reproduces the lookup `then` performs before it builds its result promise: - * `constructor`, then its `Symbol.species`. A genuine promise with a hostile - * species throws here too; an object that only claims to be a promise failed - * the brand check first and reads its `constructor` cleanly. That is what - * separates a promise nothing can subscribe to from an ordinary proof tree, - * once shadowing `constructor` has turned out to be impossible. - * - * @type {(value: unknown) => boolean} - */ -const speciesFails = value => { - try { - if (value === null || value === undefined) { return false } - const { constructor } = /** @type {{ readonly constructor?: unknown }} */ (value) - if (constructor === null || constructor === undefined) { return false } - // The species itself never matters, only whether reading it completes: - // that is the step `then` takes before it builds its result. - void /** @type {{ readonly [Symbol.species]?: unknown }} */ (constructor)[Symbol.species] - return false - } catch { - return true - } -} -/** - * Runs the intrinsic Promise `then` only for genuine promises. The first call - * is both the native brand check and the normal await path, so arbitrary proof - * objects with a `then` key are never assimilated. - * - * A genuine Promise can still throw after passing the brand check if species - * construction fails. In that case, temporarily shadow `constructor` with the - * current realm's Promise and retry the same intrinsic call; the shadow is - * removed immediately after the handlers are attached. - * - * A promise that pins its own `constructor`, or is frozen, leaves nothing to - * shadow, so no subscription is possible at all. The species failure is then - * reported against the test that produced the promise — the same outcome - * `await` gives it in the Node runner — because a result nobody can observe is - * not a pass. A non-extensible object that merely claims to be a promise - * reaches the same dead end and is still walked as the proof tree it is. - * - * @type {(value: unknown, fulfilled: (value: unknown) => Promise | readonly _BrowserTestResult[], rejected: (error: unknown) => readonly _BrowserTestResult[]) => Promise | null} - */ -const runPromise = (value, fulfilled, rejected) => { - const call = () => subscribe(value, fulfilled, rejected) - try { - return call() - } catch (error) { - // Either `value` is not a promise and the brand check rejected it - // before any handler was attached, or it is a genuine promise that - // failed while constructing the result through Symbol.species. Only - // the second case is worth a retry, and `then` attaches nothing before - // it throws, so the retry cannot run the handlers twice. - try { - if (Object.prototype.toString.call(value) !== '[object Promise]') { return null } - } catch { - return null - } - if (value === null || (typeof value !== 'object' && typeof value !== 'function')) { return null } - /** @type {PropertyDescriptor | undefined} */ - let descriptor - try { - descriptor = Object.getOwnPropertyDescriptor(value, 'constructor') - Object.defineProperty(value, 'constructor', { value: Promise, configurable: true }) - } catch { - // Nothing to shadow, so the value is whatever its own lookup says: - // a promise that cannot be subscribed to fails on the species error - // rather than passing on a result that was never awaited, and a - // frozen spoof is an ordinary proof tree. - return speciesFails(value) ? Promise.resolve(rejected(error)) : null - } - try { - return call() - } catch { - // The intrinsic `constructor` cannot fail the retry, so the brand - // check did: `value` only claims to be a promise and is walked as - // an ordinary proof result. - return null - } finally { - try { - if (descriptor === undefined) { - Reflect.deleteProperty(value, 'constructor') - } else { - Object.defineProperty(value, 'constructor', descriptor) - } - } catch { - // The temporary property is configurable, so ordinary objects - // restore cleanly. A hostile Proxy can make restoration itself - // observable. - } - } - } -} +/** @typedef {{ readonly status: string, readonly browser: string, readonly totals: { readonly tests: number, readonly passed: number, readonly failed: number }, readonly duration: number, readonly results: readonly _BrowserTestResult[] }} BrowserTestReport */ /** * A failure of a whole module — one that will not link, or whose `proof` export @@ -264,11 +146,21 @@ const runOne = (module, path, throws, fn, result) => { result(failure) return [failure] } - // Wrap the raw return so Promise resolution does not assimilate arbitrary - // objects with a `then` proof property. The Node runner awaits only actual - // promises, and browser execution must preserve that same test-tree rule. + // `instanceof Promise`, exactly as `fjs t` decides it. The value is wrapped + // in a tuple first so that resolving it cannot assimilate a proof tree that + // happens to carry a `then` key: such a tree is a sub-tree with a test + // called `then` in it, in both runners. + // + // This runner executes authored FunctionalScript and nothing else — the + // suite is selected by `website/browser-prepare.mjs` on `.f.mjs` — and + // FunctionalScript has no promises. The only promises reaching here come + // from the impure proofs that drive this module from Node, and those are + // same-realm by construction. See `todo/imports-promises-realms.md` for the + // machinery this replaces and the measurements behind removing it. return Promise.resolve().then(() => [fn()]).then( - ([value]) => runPromise(value, passed, failed) ?? passed(value), + ([value]) => value instanceof Promise + ? value.then(passed, failed) + : passed(value), failed ) } diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index c8776f55b..e11e76eee 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -206,17 +206,22 @@ export const proof = { assertEq(report.status, 'failed') assertEq(report.results[0]?.message, 'Expected the proof to throw') }, - crossRealmPromise: async () => { - // A promise built in another realm is not `instanceof Promise`. The - // runner has to await it anyway and walk the tree it resolves to, - // otherwise a rejected cross-realm promise is reported as a pass. + // A promise built in another realm is not `instanceof Promise`, so it is + // walked as an ordinary proof tree rather than awaited — which is exactly + // what `fjs t` does with it, and the point of this proof is that the two + // agree. It is a known gap in both, recorded in + // `../todo/imports-promises-realms.md`, and not one this runner may close on + // its own: a browser suite runs authored `.f.mjs` only, and FunctionalScript + // has no promises, so nothing it executes can produce this value. Only an + // impure proof reaching for `node:vm` can, as this one does. + crossRealmPromiseIsWalkedAsATree: async () => { const other = runInNewContext('({ resolve: value => Promise.resolve(value) })') const report = await run({ nested: () => other.resolve({ child: () => { throw 'boom' } }), }) - assertEq(report.totals.tests, 2) - assertEq(report.totals.failed, 1) - assertEq(report.results[1]?.path, '.nested().child') + assertEq(report.totals.tests, 1) + assertEq(report.totals.failed, 0) + assertEq(report.results[0]?.path, '.nested') }, spoofedPromiseTag: async () => { const report = await run({ @@ -268,25 +273,7 @@ export const proof = { assertStructurallySame([...p.states], ['running', 'failed']) assertEq(p.view.events.length, 1) }, - speciesResultIsNotAPromise: async () => { - // `then` builds its result through `constructor[Symbol.species]`, and a - // promise can make that an ordinary object. The run has to answer with - // the promise it subscribed to, not with what `then` handed back, or - // the test ends before the promise settles and the species object - // itself lands in the report. - const species = function (/** @type {(...args: (() => void)[]) => void} */ executor) { - executor(() => undefined, () => undefined) - return { notAPromise: true } - } - const promised = new Promise(resolve => - setTimeout(resolve, 1, { child: () => { throw 'boom' } })) - Object.defineProperty(promised, 'constructor', - { value: { [Symbol.species]: species }, configurable: true }) - const report = await run({ nested: () => promised }) - assertEq(report.totals.tests, 2) - assertEq(report.totals.failed, 1) - assertEq(report.results[1]?.path, '.nested().child') - }, + reportingThrows: async () => { // Announcing a result as it lands is the page's own rendering. It must // not take the run down with it: the report is what the page waits for. diff --git a/fjs/emergent_testing/browser/species.proof.mjs b/fjs/emergent_testing/browser/species.proof.mjs deleted file mode 100644 index 11303e009..000000000 --- a/fjs/emergent_testing/browser/species.proof.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import { assertEq } from '../../asserts/module.f.mjs' -import { runBrowserProofs } from '../browser.mjs' - -/** - * A genuine promise whose `then` always throws: the result promise is built - * through `constructor[Symbol.species]`, and this `constructor` has none to - * give. `configurable` decides whether the runner can shadow the property for - * the length of one subscription. - * - * @type {(configurable: boolean) => Promise} - */ -const throwingSpeciesPromise = configurable => { - const promised = Promise.resolve({ - child: () => { throw 'boom' }, - }) - const constructor = {} - Object.defineProperty(constructor, Symbol.species, { - get: () => { throw new Error('species') }, - }) - Object.defineProperty(promised, 'constructor', { value: constructor, configurable }) - return promised -} - -/** @type {(promised: Promise) => ReturnType} */ -const run = promised => runBrowserProofs([['proof', { nested: () => promised }]]) - -export const proof = { - throwingSpecies: async () => { - // The intrinsic Promise shadows the hostile `constructor` while the - // handlers are attached, so the resolved sub-tree still runs. - const report = await run(throwingSpeciesPromise(true)) - assertEq(report.totals.tests, 2) - assertEq(report.totals.failed, 1) - assertEq(report.results[1]?.path, '.nested().child') - }, - pinnedThrowingSpecies: async () => { - // Nothing to shadow, so the promise can never be subscribed to. The - // test that produced it fails, rather than passing on a result the - // runner never observed. - const report = await run(throwingSpeciesPromise(false)) - assertEq(report.totals.tests, 1) - assertEq(report.totals.failed, 1) - assertEq(report.results[0]?.message, 'species') - }, -} diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index 929be54bf..3c5947ada 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -64,6 +64,34 @@ imports are JSDoc comments and never produce a request), or paths outside the application root. The browser runner must not import the Node effect runner, `node:test`, Node built-ins, or Playwright. +### Scope: authored FunctionalScript only + +**The browser suite runs `.f.mjs` and nothing else.** `website/browser-prepare.mjs` +selects on `name.endsWith('.f.mjs')`; the generated manifest currently carries +137 modules, none of them anything else. That is the design, not a first +iteration to be widened later. + +It follows from what the two kinds of module are. Authored FunctionalScript is +pure — no host objects, no `node:` imports, no promises, no `async` — so a +`.f.mjs` proof means the same thing in every runner, and the extension is a +sufficient declaration for a static selector that never imports anything. An +impure `.mjs` proof means whatever its host provides: `node:fs`, `node:vm`, +`process`, `node:test`, a filesystem, a subprocess. Loading those into a page and +expecting them to test anything is not a goal — see +[impure `.mjs` proofs are Node-only](host-targeted-tests.md). + +Two things follow that are easy to get wrong: + +- **The runner needs no promise handling of its own.** FunctionalScript cannot + produce a promise, so nothing the browser executes can be one. `fjs t`'s + `instanceof Promise` is kept only so both runners' `sandbox` reads the same, + and the cross-realm machinery that used to sit here is gone. See + [imports, promises and realms](imports-promises-realms.md). +- **The impure proofs that drive the browser runner are not part of the suite.** + `emergent_testing/browser/proof.mjs` tests browser code, but it is `.mjs`, so + it runs under `fjs t` in Node against this module called as a library. Testing + the browser runner and running in a browser are different things. + ### Selection The named `proof` export is the source of truth; filenames are conventions. diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 97242a0fc..d41bb311b 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -1,7 +1,7 @@ ## Investigate imports, promises and realms **Priority:** P3 -**Status:** open — investigated; a decision is now the only thing missing +**Status:** closed for the runner; open only as a note for iframes and workers ### Problem @@ -41,7 +41,7 @@ adopted) is the thing the runner deliberately refuses to do (`then` is a name), and the check that separates them (`instanceof`) is the one that does not survive a realm boundary. -### What to investigate +### What was investigated This is a study, not a design. It is worth doing before [browser-testing](browser-testing.md) puts proofs in iframes or workers, because @@ -195,15 +195,18 @@ with a key named `then` — a proof called `then`. `p instanceof Promise` refuse it correctly, which is what `thenIsATestName` asserts and what makes the structural rule hold. -### Recommendation, revised twice +### Outcome -**Do it exactly as `fjs t` does: `p instanceof Promise`, await, done.** Delete -the species machinery and `species.proof.mjs` with it. +**Done.** The browser's `sandbox` decides with `p instanceof Promise`, exactly as +`fjs t` does; `subscribe`, `speciesFails`, `runPromise` and `species.proof.mjs` +are deleted. `crossRealmPromise` became +`crossRealmPromiseIsWalkedAsATree`, which pins the two runners agreeing rather +than the browser defending alone — the gap is real, shared, and recorded here. Strictly, the browser needs no promise handling whatever — it runs only -`.f.mjs`. Keeping `instanceof` there anyway is the cheap and honest choice: it -is one expression, it keeps the two runners' `sandbox` identical rather than -"identical except the browser omits a branch", and it costs nothing to carry. +`.f.mjs`. `instanceof` is kept anyway: one expression, and it keeps the two +runners' `sandbox` identical rather than "identical except the browser omits a +branch", which is the kind of small asymmetry drift starts from. That is not a compromise on correctness. It is correct for every value the language can produce, and for every value any proof in this repository actually diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index d6c9ae4fa..6bb7d8927 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -116,21 +116,16 @@ and is reviewable without the next one. expectation is applied through the same `invert` both runners now use, so "did this leaf pass" has one answer. Describing a *thrown value* stayed with each host, deliberately — see below. -- [ ] **3. One `sandbox`.** Executing a proof body — the clock either side, the - `try`/`catch`, and the rule that only an actual `Promise` is awaited — is - the operation both runners must agree on exactly, and the one place where - they currently do not. **This step is blocked on a decision, not on - work**: the browser carries ~150 lines of `Symbol.species` machinery that - `fjs t` has no equivalent for, so merging the two answers the cross-realm - question in [imports, promises and realms](imports-promises-realms.md). - **That investigation is now done, and it unblocks this step rather than - complicating it.** FunctionalScript has no promises: across 125 pure - `.f.mjs` proof modules there are zero leaves that return one, and all 39 - that exist are `async` functions in five impure `.mjs` proofs — same - realm, our own code. The browser's cross-realm machinery defends only - against its own fixtures. So this step is `p instanceof Promise`, exactly - as `fjs t` does it, and `species.proof.mjs` goes with the machinery. Doing it inside a port is how - the last attempt lost a defence nobody chose to lose. +- [x] **3. One `sandbox`.** Done, and it turned out to be a deletion. The + browser suite runs authored `.f.mjs` only and FunctionalScript has no + promises, so nothing the browser executes can be one: the + `Symbol.species` machinery — `subscribe`, `speciesFails`, `runPromise` and + `species.proof.mjs` — defended values the browser cannot produce, against + fixtures that are themselves `.mjs` and never run in a browser. Replaced + by `instanceof Promise`, which is what `fjs t` does. The measurements are + in [imports, promises and realms](imports-promises-realms.md); the scope + rule they rest on is in [browser testing](browser-testing.md). + - [ ] **4. Common effects.** Move the host-independent operations (`all`, `await`, `fetch`, `import`, `now`, `sandbox`) out of `effects/node` into a shared module that `effects/node` re-exports unchanged, so nothing has to @@ -147,7 +142,8 @@ and is reviewable without the next one. - [ ] **8. The layout move**, and the website preparation program. Steps 3 and 7 are the ones that change behaviour, so they are the ones to keep -smallest. Anything a step reveals goes to an issue and is fixed for both runners +smallest. Step 3 changed less than expected: with the scope written down, it was +a removal. Anything a step reveals goes to an issue and is fixed for both runners later, never inside the step. **What step 2 revealed, recorded rather than fixed.** With the status shared, From a0c1b9186a065828a3072fdd24efa00fcb608654 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:08:51 +0000 Subject: [PATCH 087/370] changelog: the browser's promise rule now matches `fjs t` --- changelog/unreleased/1742.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/unreleased/1742.md diff --git a/changelog/unreleased/1742.md b/changelog/unreleased/1742.md new file mode 100644 index 000000000..cfc80eded --- /dev/null +++ b/changelog/unreleased/1742.md @@ -0,0 +1,4 @@ +- **BREAKING CHANGES:** `emergent_testing`: the browser runner awaits only + `instanceof Promise` values, as `fjs t` does. A promise from another realm is + walked as a proof tree rather than awaited; authored FunctionalScript cannot + produce one, and the browser suite runs authored FunctionalScript only From c4eb0b26984dd1f40c5acf8c56583fdb3a2fe817 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:25:36 +0000 Subject: [PATCH 088/370] emergent_testing: await the promise, do not call its `then` The claim was parity with `fjs t`; the code was `value.then(passed, failed)`, and `fjs t` does `p = await p`. Those are different operations, and review caught two consequences, both reproduced: - `.then` builds its answer through `constructor[Symbol.species]`, so a promise with a custom species hands back an ordinary object before the proof has settled -- the run finishes early with the species object in the report and the subtree never counted. - `.then` calls the value's *own* `then`, so a no-op override yields `undefined` and the subtree is lost. `await` on a same-realm promise adopts the promise's internal state and consults neither. Measured through `runBrowserProofs` after the fix: a custom species runs its child and fails it, an own-`then` override runs its child, a rejection fails, a `{ then }` tree is still walked as a tree. So `await` recovers everything the deleted machinery gave for the values this runner can meet, which strengthens the deletion rather than undoing it. `awaitIgnoresAnOwnThenOverride` and `awaitIgnoresACustomSpecies` pin both; each fails when `.then` is restored. Also corrects the todo's checked-off task list, which still recorded the constructor-based brand check as working after the Findings had disproved it. It now says no standalone constructor-based check was accepted, and why the question stopped mattering. `fjs t` 3476/3476, `npx tsc` clean, coverage 100%. --- fjs/emergent_testing/browser.mjs | 37 ++++++++++++++----- fjs/emergent_testing/browser/proof.mjs | 32 ++++++++++++++++ .../todo/imports-promises-realms.md | 26 +++++++++---- 3 files changed, 78 insertions(+), 17 deletions(-) diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 3a3c02b17..2e9b35829 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -146,10 +146,17 @@ const runOne = (module, path, throws, fn, result) => { result(failure) return [failure] } - // `instanceof Promise`, exactly as `fjs t` decides it. The value is wrapped - // in a tuple first so that resolving it cannot assimilate a proof tree that - // happens to carry a `then` key: such a tree is a sub-tree with a test - // called `then` in it, in both runners. + // `instanceof Promise` and then `await`, which is exactly what `fjs t`'s + // `sandbox` does — and the `await` is not incidental. `value.then(a, b)` + // would be a different operation: it calls the value's *own* `then`, and it + // builds its answer through `constructor[Symbol.species]`, so a promise + // carrying either can hand back something that is not its result. `await` + // on a same-realm promise adopts the promise's internal state and consults + // neither. + // + // The value is wrapped in a tuple first so that resolving it cannot + // assimilate a proof tree carrying a `then` key: such a tree is a sub-tree + // with a test called `then` in it, in both runners. // // This runner executes authored FunctionalScript and nothing else — the // suite is selected by `website/browser-prepare.mjs` on `.f.mjs` — and @@ -157,12 +164,22 @@ const runOne = (module, path, throws, fn, result) => { // from the impure proofs that drive this module from Node, and those are // same-realm by construction. See `todo/imports-promises-realms.md` for the // machinery this replaces and the measurements behind removing it. - return Promise.resolve().then(() => [fn()]).then( - ([value]) => value instanceof Promise - ? value.then(passed, failed) - : passed(value), - failed - ) + /** @type {(value: unknown) => Promise | readonly _BrowserTestResult[]} */ + const settled = async value => { + if (!(value instanceof Promise)) { return passed(value) } + /** @type {readonly [unknown]} */ + let resolved + // Only the `await` is guarded. A throw from `passed` is the traversal's + // own and has its own handling; catching it here would report a broken + // proof tree as a rejected promise. + try { + resolved = [await value] + } catch (error) { + return failed(error) + } + return passed(resolved[0]) + } + return Promise.resolve().then(() => [fn()]).then(([value]) => settled(value), failed) } /** @type {(status: string, duration: number, results: readonly _BrowserTestResult[]) => BrowserTestReport} */ diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index e11e76eee..a3ba3e871 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -214,6 +214,38 @@ export const proof = { // its own: a browser suite runs authored `.f.mjs` only, and FunctionalScript // has no promises, so nothing it executes can produce this value. Only an // impure proof reaching for `node:vm` can, as this one does. + + // `await`, not `value.then(...)`. A promise can replace its own `then`, and + // it can make `constructor[Symbol.species]` build something that is not a + // promise at all; `.then` consults both, `await` consults neither and reads + // the promise's internal state. These two pin that the browser awaits the + // way `fjs t` does rather than merely checking the same brand. + awaitIgnoresAnOwnThenOverride: async () => { + const promised = Promise.resolve({ child: () => undefined }) + // A no-op override: anything that calls it instead of awaiting gets + // `undefined` and loses the subtree. + Object.defineProperty(promised, 'then', { value: () => undefined }) + const report = await run({ nested: () => promised }) + assertEq(report.totals.tests, 2) + assertEq(report.results[1]?.path, '.nested().child') + }, + awaitIgnoresACustomSpecies: async () => { + // `then` builds its answer through `constructor[Symbol.species]`, and + // this one returns an ordinary object, so `.then` would hand back a + // non-promise before the proof had settled. + const species = function (/** @type {(...args: (() => void)[]) => void} */ executor) { + executor(() => undefined, () => undefined) + return { notAPromise: true } + } + const promised = new Promise(resolve => + setTimeout(resolve, 1, { child: () => { throw 'boom' } })) + Object.defineProperty(promised, 'constructor', + { value: { [Symbol.species]: species }, configurable: true }) + const report = await run({ nested: () => promised }) + assertEq(report.totals.tests, 2) + assertEq(report.totals.failed, 1) + assertEq(report.results[1]?.path, '.nested().child') + }, crossRealmPromiseIsWalkedAsATree: async () => { const other = runInNewContext('({ resolve: value => Promise.resolve(value) })') const report = await run({ diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index d41bb311b..cc31ffeb3 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -50,10 +50,14 @@ that is the point at which cross-realm promises stop being hypothetical. - **State the layering.** One document saying which layer adopts a `then` and which layer refuses to, and why both are right. Until that exists, every fix to one looks like a bug in the other. -- [x] **Find a brand check that survives a realm and cannot be forged.** Done — - see Findings. `Promise.resolve(p) === p` against the value's own constructor - works, combined with `instanceof`; it is forgeable only deliberately. - Whatever is chosen must be one function every interpreter calls. +- [x] **Find a brand check that survives a realm and cannot be forged.** + Answered, and the answer is **no standalone constructor-based check was + accepted**. `Promise.resolve(p) === p` against the value's own constructor + looked right and misclassifies an ordinary identity-`resolve` tree, hanging + the run; and no brand check reaches the case where the value *is* a promise + and the subscription is the defect. See Findings. The question stopped + mattering once the scope was written down: the browser runs `.f.mjs` only, so + it never meets a promise it did not create. - **Decide whether the runner should see namespace objects at all.** If discovery handed the runner a plain record of proofs rather than the module namespace, the `then` export hazard would not reach it — and the `then`-export @@ -197,9 +201,17 @@ structural rule hold. ### Outcome -**Done.** The browser's `sandbox` decides with `p instanceof Promise`, exactly as -`fjs t` does; `subscribe`, `speciesFails`, `runPromise` and `species.proof.mjs` -are deleted. `crossRealmPromise` became +**Done.** The browser's `sandbox` decides with `p instanceof Promise` and then +**`await`s** — exactly as `fjs t` does, and the `await` is the load-bearing half. +`value.then(a, b)` is a different operation: it calls the value's own `then` and +builds its answer through `constructor[Symbol.species]`, either of which a +promise can replace, so a proof's subtree can be lost or the run handed a +non-promise. `await` on a same-realm promise adopts internal state and consults +neither, which is why three lines recover everything the machinery gave for the +values this runner can actually meet. `awaitIgnoresAnOwnThenOverride` and +`awaitIgnoresACustomSpecies` pin both, and both fail against `.then`. + +`subscribe`, `speciesFails`, `runPromise` and `species.proof.mjs` are deleted. `crossRealmPromise` became `crossRealmPromiseIsWalkedAsATree`, which pins the two runners agreeing rather than the browser defending alone — the gap is real, shared, and recorded here. From e1b2702a4b6cf6fb583522460191e576773f38a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:35:37 +0000 Subject: [PATCH 089/370] todo: record what the missing logic/glue boundary cost `capl.md` argues identity-by-origin from arrays and from diamond dependencies. The language's own built-ins have the same defect and it is sharper: every realm has its own `Promise`, identical in definition and meaning and unequal in identity, so `instanceof` asks which copy of the constructor made a value rather than what the value is. Adds that as a measured example rather than another abstract one. Deciding whether a value is a promise cost several rounds of design, review and reversal in the browser proof runner; two proposed answers were measured and found to hang the suite rather than misreport; ~150 lines of `Symbol.species` machinery had accumulated to defend against values the runner cannot produce. No identity check fixes it -- a genuine cross-realm promise passes every one of them and the defect is in what follows. Separation fixed it: the runner executes only pure FunctionalScript, which has no promises, and the machinery and its fixtures were deleted together. Notes that this repository already does the other half, and names it: a live HTTP server reaches pure code as `Nominal<'server', '160855c4...', unknown>`, a handle whose type identity is a content hash, with the real object held only by the interpreter. `share-browser-console-runner.md` gains the same point as motivation for steps 4-7, which look like tidying and are actually that boundary: when they are done `instanceof Promise` lives in one interpreter as glue, and no shared code asks an origin question. `fjs t` already escapes this because `sandbox` is an operation, not because it is more careful. --- .../todo/share-browser-console-runner.md | 38 +++++++++++++++++++ todo/plan/capl.md | 34 +++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 6bb7d8927..2cd9d6923 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -168,6 +168,44 @@ the walk's state rather than from the reporter, so they stay honest and the summary still reports the failures. Worth remembering when reading output while changing this function. +### Why the remaining steps are worth taking + +Steps 4 through 7 look like tidying — move some operations, add an interpreter, +share a reporter, delete a traversal. They are not. They draw a boundary the +browser runner does not have, and the promise episode is what its absence costs. + +`browser.mjs` is impure `.mjs`, so a live host promise and a proof tree travel +the same code path, and the code has to ask *which of these is a promise?* That +is an identity-by-origin question — `instanceof` asks which copy of the +constructor made the value, not what the value is — and asking it in a place +that handles business logic is what produced ~150 lines of `Symbol.species` +machinery, several rounds of review, two measured ways to hang the suite, and a +reversal. The answer, in the end, was that the question should not have been +there: the runner executes only pure FunctionalScript, which has no promises. + +`fjs t` mostly escapes this already, and not by being more careful. `sandbox` is +an *operation*: the promise is awaited inside the interpreter and the pure core +receives a `SandboxResult`. The host value never reaches the logic. That is the +same discipline `fjs/effects` applies to a live HTTP server, which pure code +holds as `Nominal<'server', '160855c4…', unknown>` — a handle whose identity is +a content hash, with the real object kept by the interpreter. + +So the remaining steps are that boundary, applied to the browser: + +- **step 4** puts the host-independent operations somewhere both hosts can name; +- **step 5** gives the browser an interpreter, which is where its host values + belong; +- **steps 6 and 7** move reporting and traversal into the pure core, which is + where host values must never be. + +When they are done, `instanceof Promise` lives in exactly one interpreter, as +glue, and no shared code asks the question. The three lines in `browser.mjs` +today are in the right *place* only because the boundary has not been drawn +there yet — they are temporary in a way the rest of the shared core is not. +See [`todo/plan/capl.md`](../../../todo/plan/capl.md), which argues the general +form: logic pure, serializable and content-addressed; host values behind +handles. + ### Preliminary design Share semantics, not host mechanics. The console runner should keep using the diff --git a/todo/plan/capl.md b/todo/plan/capl.md index 2065857f4..698f84704 100644 --- a/todo/plan/capl.md +++ b/todo/plan/capl.md @@ -43,6 +43,40 @@ Even though `a` and `b` are structurally identical (both empty arrays), `a !== b More broadly, reference-based object identity makes most modern languages subtly non-deterministic: you can never tell from the code alone whether `b` is a clone of `a` (sharing structure, potentially sharing mutations) or independently reconstructed from scratch (a separate object that happens to look the same). This ambiguity infects testing, serialization, distributed state, and caching. In a CA language the question disappears: `b` is `a` if and only if they have the same hash. Identity is observable, not hidden in a pointer. +**Built-ins have identity too, and it costs real time.** The example above uses +arrays, but the same defect reaches the language's own types. Every realm — an +iframe, a worker, a `node:vm` context — has its own `Promise`, `Array`, `Error`, +identical in definition and meaning and unequal in identity. `x instanceof +Promise` does not ask *is this a promise*; it asks *was this made by my copy of +the constructor*. A perfectly ordinary promise from an iframe answers no. + +That is the diamond dependency problem and the broken-`instanceof`-after- +deserialization problem in a third guise: identity by origin, where shape was +what mattered. And it is not theoretical. Building this repository's browser +proof runner, that one question — *is this value a promise?* — consumed several +rounds of design, review, and reversal. Two proposed answers were measured and +found to **hang** the test suite rather than merely misreport. About 150 lines of +`Symbol.species` machinery accumulated to defend against values the runner +turned out to be incapable of producing. + +Every one of those rounds was spent because host values and business logic were +sharing a code path. The fix was not a cleverer identity check — no check +works, since a genuine cross-realm promise passes every one of them and the +defect is in what happens next. The fix was **separation**: the runner executes +only pure FunctionalScript, which has no promises, so the question never arises. +The 150 lines and the fixtures testing them were deleted together. + +That is the argument for CA and for effects as one argument rather than two. +Business logic should be pure, serializable and content-addressed, where identity +is shape and comparison is a hash. Host values — sockets, DOM nodes, promises — +belong in the glue, reached through effects and opaque handles, never flowing +through the logic. This repository already does the second half: `fjs/effects` +represents a live HTTP server to pure code as +`Nominal<'server', '160855c4…', unknown>` — a handle whose type identity *is* a +content hash, with the real object held only by the interpreter. Where that +boundary is drawn, identity questions do not come up. Where it is not, they cost +weeks. + **Hashing is incremental and shallow.** The hash of a compound value — an array, an object, a function — is computed from the hashes of its constituent parts, not from the raw bytes of the entire sub-tree. The hash of an array is the hash of its elements' hashes; the hash of an object is the hash of its (canonically ordered) property hashes; the hash of a function is the hash of its normalized EDAG node hashes. This forms a Merkle DAG: every referenced value already has its hash, so computing the hash of a new value is a shallow operation — one level deep, regardless of how deeply nested the structure is. No implementation needs to re-traverse sub-objects; their hashes are already known. This makes CA hashing efficient and composable by construction. **Structural provenance.** Because the system detects duplicates globally, it can also surface provenance: if your implementation of `qsort` normalizes to a hash that already exists in thousands of packages, the system can tell you. You reimplemented it independently — for learning, for fun, from first principles — and the system can confirm your implementation is exactly equivalent to many other known implementations. This builds trust without requiring you to copy anyone's code. It also opens a new kind of learning: browse the existing packages that share your hash, see how others use the same algorithm, discover variations you hadn't considered. Independent rediscovery becomes verifiable. From 415bc2b8ed2b1aa44d390d81ff3d690894c44a51 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:41:57 +0000 Subject: [PATCH 090/370] todo: record the browser runner as migration debt under the new rule The merged guidance says business logic under `fjs/` belongs in `.f.mjs` and plain `.mjs` is a thin host boundary, with existing violations filed as co-located debt. `emergent_testing/browser.mjs` is the largest one outstanding. Measured by whether a definition touches a host object at all: about 50 lines are genuine DOM glue, about 30 are already pure, and about 200 are logic wearing one thin host touch -- `runOne` walks a proof tree and builds results but reads `performance.now()`; `reportOf` computes totals but reads `navigator.userAgent`; `startBrowserTestSources` sequences loading but calls `import()`. The proof file is the visible cost. `browser/proof.mjs` is 493 lines, three times the next impure proof file in the repository, and it exists to drive that logic from Node through a DOM stand-in. Logic in `.f.mjs` would be proven by an ordinary `proof.f.mjs`; thin glue needs few `.mjs` proofs, so the size of that file measures how thick the glue has become. Steps 4-7 of `share-browser-console-runner.md` already are this extraction, reached from the sharing side, so the new file is the debt record rather than a competing plan. It adds two things that plan does not: that `errorDetails` and `text` are pure in substance but need `try`/`catch`, so they cannot be FunctionalScript until the `catch` operation exists; and what `browser.mjs` should still hold when the work is done -- a plausible 50-80 lines against 405 today. --- .../todo/browser-runner-functional-script.md | 78 +++++++++++++++++++ .../todo/share-browser-console-runner.md | 7 ++ 2 files changed, 85 insertions(+) create mode 100644 fjs/emergent_testing/todo/browser-runner-functional-script.md diff --git a/fjs/emergent_testing/todo/browser-runner-functional-script.md b/fjs/emergent_testing/todo/browser-runner-functional-script.md new file mode 100644 index 000000000..727e0ee8f --- /dev/null +++ b/fjs/emergent_testing/todo/browser-runner-functional-script.md @@ -0,0 +1,78 @@ +## Move the browser runner's business logic to FunctionalScript + +**Priority:** P2 +**Status:** open — migration debt + +### Problem + +[`browser.mjs`](../browser.mjs) is a plain `.mjs` file holding a whole test +runner. Under the repository rule that business logic belongs in `.f.mjs` and +plain `.mjs` is a thin host boundary, most of it is in the wrong place. + +Measured by whether a definition touches a host object at all: + +| | lines | +| --- | --- | +| genuine DOM/window glue (`setState`, `render*`, `publish`, `viewOf`) | ~50 | +| logic wearing one thin host touch (`runOne`, `runBrowserProofs`, `reportOf`, `startBrowserTestSources`) | ~200 | +| pure already (`text`, `errorDetails`, `moduleFailure`) | ~30 | + +The middle row is the debt. `runOne` walks a proof tree, builds results and +recurses — business logic — and is "impure" only because it reads +`performance.now()` and awaits. `reportOf` computes totals and reads +`navigator.userAgent`. `startBrowserTestSources` sequences loading and calls +`import()`. In each case a few host touches keep two hundred lines of logic out +of FunctionalScript. + +**The proof file is the visible cost.** [`browser/proof.mjs`](../browser/proof.mjs) +is 493 lines — three times the next impure proof file in the repository — and it +exists to test that logic from Node through a DOM stand-in. Logic in `.f.mjs` +would be proven by an ordinary co-located `proof.f.mjs`; only the DOM adapter +would still need an impure proof. Thin glue needs few `.mjs` proofs, and the +size of this one is a measurement of how thick the glue has become. + +### This is mostly already planned + +[Share the browser and console proof runners](share-browser-console-runner.md) +steps 4–7 are this extraction, arrived at from the sharing side rather than the +purity side: + +- **step 4** moves the host-independent operations to a shared module; +- **step 5** gives the browser an interpreter — where its host touches belong; +- **step 6** shares reporting; +- **step 7** deletes `runOne` outright, because the shared traversal in + `../module.f.mjs` already does what it does. + +So this file is not a competing plan. It is the migration-debt record the rule +asks for, and it names two things the sharing plan does not: + +- **`errorDetails` and `text` cannot move as they stand.** Both are pure in + substance and both need `try`/`catch`, which FunctionalScript does not have — + reading `message`, `stack` or calling `String` on a hostile value can throw. + Moving them needs the `catch` operation designed in + [hostile proof values](hostile-proof-values.md). Until then they are pure logic + that legitimately cannot be `.f.mjs`, which is worth stating so it is not read + as laziness. +- **What should remain.** When the extraction is done, `browser.mjs` should hold + the DOM adapter, the published promise and completion event, the loading + importer, and the interpreter for the browser's operations. That is a plausible + 50–80 lines, against 405 today. + +### Tasks + +- [ ] Extract the proof-tree walk, result building and totals into `.f.mjs` + (steps 4–7 above), and shrink `browser/proof.mjs` to the DOM adapter's own + proofs as the logic moves. +- [ ] Record what cannot move and why — starting with the `try`/`catch` in + `errorDetails`. +- [ ] Check the remaining `.mjs` against the same measure once done: a host + touch per definition, not a definition per file. + +### Related + +- [Share the browser and console proof runners](share-browser-console-runner.md) + — steps 4–7 are this work. +- [Hostile thrown values](hostile-proof-values.md) — the `catch` operation + `errorDetails` needs before it can be FunctionalScript. +- [Move browser source analysis to FunctionalScript](../../website/todo/browser-source-functional-script.md) + — the same debt in `fjs/website`. diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 2cd9d6923..eb5328887 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -190,6 +190,13 @@ same discipline `fjs/effects` applies to a live HTTP server, which pure code holds as `Nominal<'server', '160855c4…', unknown>` — a handle whose identity is a content hash, with the real object kept by the interpreter. +The repository rule now says this outright — business logic in `.f.mjs`, plain +`.mjs` only as a thin host boundary — and by that measure `browser.mjs` is +migration debt: roughly 200 of its 405 lines are logic wearing one host touch. +That is recorded in +[move the browser runner's business logic to FunctionalScript](browser-runner-functional-script.md), +which is the same work seen from the purity side rather than the sharing side. + So the remaining steps are that boundary, applied to the browser: - **step 4** puts the host-independent operations somewhere both hosts can name; From 4e26469d145f8b23445e9e8c298b8536ff70b3f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:53:07 +0000 Subject: [PATCH 091/370] emergent_testing: guard the promise brand check, record the subclass hang Two findings from review, different in kind. `instanceof` consults `getPrototypeOf`, which a proxy can trap and a revoked one always throws from. `fjs t` performs that check inside `sandbox`'s `try`/`catch` and reports such a value as its test's failure; the browser performed it in a `.then` fulfillment handler with no enclosing `try`, so the whole run rejected and the page never left `running` -- no report, no completion event, the one outcome an automated controller cannot act on. Measured: browser rejected where `fjs t` caught. Now guarded, with `hostileBrandCheckIsReported` pinning it. A same-realm `Promise` subclass with an overridden `then` hangs, because `await` adopts internal state only when the constructor is the intrinsic `Promise` and otherwise assimilates through `then`. Measured in both runners, identically -- it is a property of the shared rule rather than a browser regression, so it is recorded in `todo/imports-promises-realms.md` rather than patched in one host. Not fixed on purpose: the intrinsic-`then` subscription would fix it, and reintroducing that machinery for a value the runner cannot meet is the trade this issue already rejected -- authored FunctionalScript has no `Promise`, no `class` and no `extends`. It is also a special case of something more general: any proof returning a never-settling promise hangs any runner, and bounding a proof's running time is the answer to that. `fjs t` 3477/3477, `npx tsc` clean. --- fjs/emergent_testing/browser.mjs | 14 ++++++- fjs/emergent_testing/browser/proof.mjs | 14 +++++++ .../todo/imports-promises-realms.md | 37 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 2e9b35829..8fcb72b4f 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -166,7 +166,19 @@ const runOne = (module, path, throws, fn, result) => { // machinery this replaces and the measurements behind removing it. /** @type {(value: unknown) => Promise | readonly _BrowserTestResult[]} */ const settled = async value => { - if (!(value instanceof Promise)) { return passed(value) } + // Even the brand check runs user code: `instanceof` consults + // `getPrototypeOf`, which a proxy can trap and a revoked one always + // throws from. `fjs t` performs this check inside `sandbox`'s + // `try`/`catch`, so it reports such a value as its test's failure; this + // handler has no enclosing `try`, so without one here the whole run + // rejects and the page never leaves `running`. + let isPromise = false + try { + isPromise = value instanceof Promise + } catch (error) { + return failed(error) + } + if (!isPromise) { return passed(value) } /** @type {readonly [unknown]} */ let resolved // Only the `await` is guarded. A throw from `passed` is the traversal's diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index a3ba3e871..fc7043e67 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -215,6 +215,20 @@ export const proof = { // has no promises, so nothing it executes can produce this value. Only an // impure proof reaching for `node:vm` can, as this one does. + // The brand check itself runs user code: `instanceof` consults + // `getPrototypeOf`, and a proxy can trap it. `fjs t` checks inside + // `sandbox`'s `try`/`catch` and reports the value as its test's failure; + // the page must do the same, because a run that rejects leaves it in + // `running` with no report and no completion event — the one outcome an + // automated controller cannot act on. + hostileBrandCheckIsReported: async () => { + const report = await run({ + nested: () => new Proxy({}, { getPrototypeOf: () => { throw 'trap' } }), + }) + assertEq(report.status, 'failed') + assertEq(report.results[0]?.path, '.nested') + assertEq(report.results[0]?.message, 'trap') + }, // `await`, not `value.then(...)`. A promise can replace its own `then`, and // it can make `constructor[Symbol.species]` build something that is not a // promise at all; `.then` consults both, `await` consults neither and reads diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index cc31ffeb3..0b24de98d 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -210,6 +210,10 @@ non-promise. `await` on a same-realm promise adopts internal state and consults neither, which is why three lines recover everything the machinery gave for the values this runner can actually meet. `awaitIgnoresAnOwnThenOverride` and `awaitIgnoresACustomSpecies` pin both, and both fail against `.then`. +`hostileBrandCheckIsReported` pins the third thing `fjs t` does that the page +must too: run the `instanceof` inside a guard, because the check consults +`getPrototypeOf` and a proxy can trap it — unguarded, the run rejects and the +page never leaves `running`. `subscribe`, `speciesFails`, `runPromise` and `species.proof.mjs` are deleted. `crossRealmPromise` became `crossRealmPromiseIsWalkedAsATree`, which pins the two runners agreeing rather @@ -243,6 +247,39 @@ brand check and the subscription, its `Reflect.apply` must sit outside a `new Promise` executor, and a throw from it must not be conflated with "not a promise". Reach for it then, not now. +### Known shared gap: a `Promise` subclass with an overridden `then` + +`await` adopts a promise's internal state only when its `constructor` is the +intrinsic `Promise`. For a subclass — or a native promise whose `constructor` +has been replaced — resolution assimilates the value by calling its `then` +instead, so a no-op override never settles and the run hangs: + +| value | `fjs t` | browser | +| --- | --- | --- | +| `class Sub extends Promise { then() {} }`, resolved | **HUNG** | **HUNG** | + +Measured; both runners, identically, because both decide with `instanceof +Promise` and then `await`. That sameness is the point: it is a property of the +shared rule, not a browser regression, and it is recorded here rather than +patched in one host. + +**Not fixed, deliberately.** The intrinsic-`then` subscription described above +would fix it, and reintroducing that machinery to defend a value the runner +cannot meet is the trade this issue already rejected: authored FunctionalScript +has no `Promise`, no `class`, and no `extends`, so only an impure `.mjs` proof +can construct this — the same category as the fixtures deleted with the +machinery. + +It is also worth being clear about what it is a special case of. **Any** proof +returning a promise that never settles hangs any runner — +`() => new Promise(() => {})` needs no subclass and no override. A runner that +survived the subclass case would still hang on that one. Bounding a proof's +running time is the general answer, and it is not this issue. + +If the day comes that a proof legitimately returns a `Promise` subclass, the +material for the fix is above, and the fix belongs in the shared `sandbox` so +both runners get it at once. + ### Constraints - An object carrying a `then` proof property must stay an ordinary proof tree. From 0b79b6e84d56ecafa14ce0a863d4442167667ae8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 21:08:59 +0000 Subject: [PATCH 092/370] Correct an overstated claim: the .f.mjs purity rule is not enforced Review tested the premise instead of reading it, and it is false as written. "FunctionalScript has no promises and cannot produce one" is a statement about the language; the toolchain provides no such guarantee. Selection is `name.endsWith('.f.mjs')` with no content check, so a module that does not conform is loaded anyway. Verified at this head: a `.f.mjs` returning `Promise.resolve(...)` is selected -- "browser proof modules: 138 of 138" -- and awaited correctly. The sentence had spread to five places, including `changelog/unreleased/1742.md`, which would have shipped it as fact, and `todo/plan/capl.md`, where it was load-bearing for an architecture argument. All are corrected to say what is true: the language as specified has no promises, the convention is why proofs are scarce, and nothing enforces it. And the reviewer's sharper point is taken -- the claim was not only false but unnecessary. The deletion rests on what `await` does: it ignores an overridden `then` and a custom `Symbol.species`, which is what `awaitIgnoresAnOwnThenOverride` and `awaitIgnoresACustomSpecies` pin. The comment in `browser.mjs` and the changelog now say that instead of appealing to what the language can express. `capl.md` keeps the example and gains the honest residue: a language whose purity is checked rather than agreed would close the last gap. Also drops a stale present-tense reference to `species.proof.mjs` from `host-targeted-tests.md`, in the change that deletes it. `fjs t` 3477/3477, `npx tsc` clean. --- changelog/unreleased/1742.md | 5 +++-- fjs/emergent_testing/browser.mjs | 11 ++++++----- fjs/emergent_testing/browser/proof.mjs | 6 +++--- fjs/emergent_testing/todo/host-targeted-tests.md | 7 +++---- .../todo/imports-promises-realms.md | 14 ++++++++++---- .../todo/share-browser-console-runner.md | 11 ++++++----- todo/plan/capl.md | 9 ++++++--- 7 files changed, 37 insertions(+), 26 deletions(-) diff --git a/changelog/unreleased/1742.md b/changelog/unreleased/1742.md index cfc80eded..51125790a 100644 --- a/changelog/unreleased/1742.md +++ b/changelog/unreleased/1742.md @@ -1,4 +1,5 @@ - **BREAKING CHANGES:** `emergent_testing`: the browser runner awaits only `instanceof Promise` values, as `fjs t` does. A promise from another realm is - walked as a proof tree rather than awaited; authored FunctionalScript cannot - produce one, and the browser suite runs authored FunctionalScript only + walked as a proof tree rather than awaited, the gap `fjs t` has had all along. + A same-realm promise is awaited, and `await` — unlike `then` — ignores an + overridden `then` and a custom `Symbol.species` diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 8fcb72b4f..c3a8092c5 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -158,11 +158,12 @@ const runOne = (module, path, throws, fn, result) => { // assimilate a proof tree carrying a `then` key: such a tree is a sub-tree // with a test called `then` in it, in both runners. // - // This runner executes authored FunctionalScript and nothing else — the - // suite is selected by `website/browser-prepare.mjs` on `.f.mjs` — and - // FunctionalScript has no promises. The only promises reaching here come - // from the impure proofs that drive this module from Node, and those are - // same-realm by construction. See `todo/imports-promises-realms.md` for the + // What makes this enough is the `await` above, not an assumption about the + // values that reach it. FunctionalScript as specified has no promises, and + // the browser suite selects `.f.mjs` — but that selection is by filename + // with no content check (`website/browser-prepare.mjs`), so a module that + // does not conform is still loaded and can return one. The handling here is + // correct either way. See `todo/imports-promises-realms.md` for the // machinery this replaces and the measurements behind removing it. /** @type {(value: unknown) => Promise | readonly _BrowserTestResult[]} */ const settled = async value => { diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index fc7043e67..c62d7dc2c 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -211,9 +211,9 @@ export const proof = { // what `fjs t` does with it, and the point of this proof is that the two // agree. It is a known gap in both, recorded in // `../todo/imports-promises-realms.md`, and not one this runner may close on - // its own: a browser suite runs authored `.f.mjs` only, and FunctionalScript - // has no promises, so nothing it executes can produce this value. Only an - // impure proof reaching for `node:vm` can, as this one does. + // its own. Reaching it needs `node:vm`, an iframe or a worker, which + // FunctionalScript as specified cannot express — so only an impure proof + // can build one, as this one does. // The brand check itself runs user code: `instanceof` consults // `getPrototypeOf`, and a proxy can trap it. `fjs t` checks inside diff --git a/fjs/emergent_testing/todo/host-targeted-tests.md b/fjs/emergent_testing/todo/host-targeted-tests.md index 166f1a56e..a97baca1d 100644 --- a/fjs/emergent_testing/todo/host-targeted-tests.md +++ b/fjs/emergent_testing/todo/host-targeted-tests.md @@ -34,10 +34,9 @@ say why it cannot be written as `.f.mjs`. Two things a design would then have to face, both easy to miss: - **Targeting and describing are different questions.** - `emergent_testing/browser/proof.mjs` and `species.proof.mjs` *test* browser - code but *run* in Node, against the browser runner called as a library with a - DOM stand-in. A filename convention that conflates the two would mislabel - exactly those files. + `emergent_testing/browser/proof.mjs` *tests* browser code but *runs* in Node, + against the browser runner called as a library with a DOM stand-in. A filename + convention that conflates the two would mislabel exactly that file. - **A declaration is a claim, and claims need checking.** A test declaring `browser` while importing `node:fs` is a lie the preparation program has to catch — the dependency-graph acceptance diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 0b24de98d..8d0e95ade 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -157,8 +157,14 @@ shadow in `runPromise` are for. They are not decoration. ### Who is this for? — the question the study should have asked first -**FunctionalScript has no promises and cannot produce one.** A `.f.mjs` proof is -pure: no `async`, no `await`, nothing that constructs a `Promise`. So every +**FunctionalScript as specified has no promises, and nothing enforces that.** A +conforming `.f.mjs` proof is pure: no `async`, no `await`, nothing that +constructs a `Promise`. But selection is by filename — +`website/browser-prepare.mjs` is a bare `name.endsWith('.f.mjs')` with no +content check — so a module that does not conform is loaded anyway. Verified: a +`.f.mjs` returning `Promise.resolve(...)` is selected (138 of 138) and awaited +correctly. Treat what follows as a statement about the *convention*, which is +why proofs are scarce, not as a guarantee the toolchain provides. So every promise this runner has ever awaited comes from a hand-written *impure* `.mjs` proof. Counted: @@ -178,8 +184,8 @@ with `node:vm`, an iframe or a worker. The only proofs that do are the ones testing the cross-realm machinery. **The defence exists to defend against its own fixtures**, and deleting both leaves nothing uncovered. -**In the browser it is stronger than that: a promise cannot occur at all.** The -browser suite runs authored FunctionalScript and nothing else — +**In the browser it is stronger than that: by convention a promise does not +occur.** The browser suite runs authored FunctionalScript and nothing else — `website/browser-prepare.mjs` line 16 is `name => name.endsWith('.f.mjs')`, and the generated manifest carries 137 modules, none of them anything else. Impure `.mjs` proofs are excluded by construction, and rightly so: a browser has no diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index eb5328887..c1297405e 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -117,11 +117,10 @@ and is reviewable without the next one. "did this leaf pass" has one answer. Describing a *thrown value* stayed with each host, deliberately — see below. - [x] **3. One `sandbox`.** Done, and it turned out to be a deletion. The - browser suite runs authored `.f.mjs` only and FunctionalScript has no - promises, so nothing the browser executes can be one: the `Symbol.species` machinery — `subscribe`, `speciesFails`, `runPromise` and - `species.proof.mjs` — defended values the browser cannot produce, against - fixtures that are themselves `.mjs` and never run in a browser. Replaced + `species.proof.mjs` — was exercised only by fixtures that are themselves + `.mjs` and so never run in a browser, and `await` handles every case it + covered that a same-realm promise can present. Replaced by `instanceof Promise`, which is what `fjs t` does. The measurements are in [imports, promises and realms](imports-promises-realms.md); the scope rule they rest on is in [browser testing](browser-testing.md). @@ -181,7 +180,9 @@ constructor made the value, not what the value is — and asking it in a place that handles business logic is what produced ~150 lines of `Symbol.species` machinery, several rounds of review, two measured ways to hang the suite, and a reversal. The answer, in the end, was that the question should not have been -there: the runner executes only pure FunctionalScript, which has no promises. +there: the runner executes authored FunctionalScript, which by convention has +no promises — a convention nothing enforces, which is itself part of the +problem. `fjs t` mostly escapes this already, and not by being more careful. `sandbox` is an *operation*: the promise is awaited inside the interpreter and the pure core diff --git a/todo/plan/capl.md b/todo/plan/capl.md index 698f84704..0f95f92aa 100644 --- a/todo/plan/capl.md +++ b/todo/plan/capl.md @@ -62,9 +62,12 @@ turned out to be incapable of producing. Every one of those rounds was spent because host values and business logic were sharing a code path. The fix was not a cleverer identity check — no check works, since a genuine cross-realm promise passes every one of them and the -defect is in what happens next. The fix was **separation**: the runner executes -only pure FunctionalScript, which has no promises, so the question never arises. -The 150 lines and the fixtures testing them were deleted together. +defect is in what happens next. The fix was **separation**: the runner executes authored FunctionalScript, which +by convention has no promises, so the question stops arising. The 150 lines and +the fixtures testing them were deleted together — and the residual honesty is +that the convention is not enforced, since module selection is by filename. A +language whose purity is checked rather than agreed would close that last gap +too. That is the argument for CA and for effects as one argument rather than two. Business logic should be pure, serializable and content-addressed, where identity From 9e5eb18359cb6425b7e351cf145b6b5bd9a67dd1 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 14:36:21 -0700 Subject: [PATCH 093/370] chore: apply RTTI proof design update --- .github/workflows/zz-rtti-proof-doc.yml | 106 ++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .github/workflows/zz-rtti-proof-doc.yml diff --git a/.github/workflows/zz-rtti-proof-doc.yml b/.github/workflows/zz-rtti-proof-doc.yml new file mode 100644 index 000000000..fa69f3e6a --- /dev/null +++ b/.github/workflows/zz-rtti-proof-doc.yml @@ -0,0 +1,106 @@ +name: Apply RTTI proof design update + +on: + push: + branches: + - docs/rtti-proof-optimization + +permissions: + contents: write + +jobs: + update: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: docs/rtti-proof-optimization + - name: Update RTTI type-system design + shell: python + run: | + from pathlib import Path + + path = Path('todo/rtti-type-system.md') + text = path.read_text() + marker = '#### 4. Scope: FunctionalScript files only' + if '#### 4. Proofs replace unchecked casts and enable specialization' in text: + raise SystemExit(0) + + section = r'''#### 4. Proofs replace unchecked casts and enable specialization + +This type system is **built on top of FunctionalScript; it is not part of the +FunctionalScript specification**. FunctionalScript itself does not know about +types. RTTI schemas, inference, assertions, and the checker are a higher-level +system whose programs are still FunctionalScript programs. + +Unlike TypeScript, the system should have no unchecked equivalent of `as`, +`as any`, double casts, or another way to override the checker without evidence. +A required property is established in one of two ways: + +1. **Static proof.** The compiler proves that the value satisfies the required + RTTI type or refinement. +2. **Runtime proof.** An assertion checks the property and narrows the value on + the successful branch. + +An assertion is a check, not a cast: if it fails, evaluation cannot continue on +the narrowed branch. This gives the system a simple invariant: every type claim +is backed by either a compile-time proof or a runtime check. + +Runtime proofs are also optimization facts. An assertion is ordinary +computation in the EDAG; if later analysis proves that it cannot fail, its +failure branch is dead and the check can be removed. A conservative compiler +may therefore leave more assertions at run time, while a stronger compiler can +remove them without weakening type safety. + +The same proofs can justify specialization. For example, proving that an input +to a SHA-256 implementation is a `bigint` that fits in 256 bits can replace +generic arbitrary-precision operations with fixed-width operations and enable +lowering to target-specific machine instructions. + +Matrices and tensors are the same story. Proofs may establish dimensions, +shapes, element types, layout, alignment, bounds, sparsity, or device placement. +Once known, generic FunctionalScript code can be transformed into fixed loops, +vector operations, tiled matrix operations, fused kernels, or code targeting +CPUs, GPUs, NPUs, and other accelerators. + +**Automatic differentiation is another transformation enabled by the same +model.** A pure computation represented as an EDAG can be transformed into a +new computation that calculates its derivatives. Reverse-mode automatic +differentiation can construct the backward graph used for neural-network +training: + +```text +x -> matmul -> relu -> matmul -> loss + | + v + differentiation + | + v + gradients of weights and x +``` + +The generated gradient graph is itself subject to the same proofs and +optimizations: known tensor shapes and element types can specialize gradient +operations, dead intermediates can disappear, forward and backward operations +can be fused, and the result can be lowered to GPUs or other specialized +hardware. + +The broader goal is that **domain-specific semantics should be expressible as +libraries, proofs, and program transformations rather than requiring a new +language for every domain**. FunctionalScript remains the small semantic +foundation; RTTI/refinement systems, tensor libraries, automatic +differentiation, and hardware lowering live on top of it. + +''' + if marker not in text: + raise SystemExit(f'marker not found: {marker}') + path.write_text(text.replace(marker, section + marker, 1)) + - name: Commit update + run: | + if git diff --quiet; then exit 0; fi + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add todo/rtti-type-system.md + git commit -m "docs: connect RTTI proofs to optimization" + git push From 55440031528d39e4ae4a66fdf602ca4e7bc11aa6 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 14:36:47 -0700 Subject: [PATCH 094/370] chore: trigger RTTI doc update from PR --- .github/workflows/zz-rtti-proof-doc.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/zz-rtti-proof-doc.yml b/.github/workflows/zz-rtti-proof-doc.yml index fa69f3e6a..01140edab 100644 --- a/.github/workflows/zz-rtti-proof-doc.yml +++ b/.github/workflows/zz-rtti-proof-doc.yml @@ -4,6 +4,9 @@ on: push: branches: - docs/rtti-proof-optimization + pull_request: + branches: + - main permissions: contents: write From d56d99cfbcb675fd103813677ac8caaa48398a7c Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 14:36:57 -0700 Subject: [PATCH 095/370] chore: trigger RTTI doc update --- todo/.rtti-proof-update-trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 todo/.rtti-proof-update-trigger diff --git a/todo/.rtti-proof-update-trigger b/todo/.rtti-proof-update-trigger new file mode 100644 index 000000000..5c33b151d --- /dev/null +++ b/todo/.rtti-proof-update-trigger @@ -0,0 +1 @@ +trigger From 1c45ac760002459eeb8cbf6285271017caa75b07 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 14:37:12 -0700 Subject: [PATCH 096/370] chore: prepare RTTI proof design PR --- todo/.rtti-proof-pr-trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 todo/.rtti-proof-pr-trigger diff --git a/todo/.rtti-proof-pr-trigger b/todo/.rtti-proof-pr-trigger new file mode 100644 index 000000000..4ce5b7361 --- /dev/null +++ b/todo/.rtti-proof-pr-trigger @@ -0,0 +1 @@ +trigger pull request workflow From f9ba673952ec3811049929b56f7c27f6c396d6ce Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 14:37:17 -0700 Subject: [PATCH 097/370] chore: trigger PR workflow --- todo/.rtti-proof-pr-trigger2 | 1 + 1 file changed, 1 insertion(+) create mode 100644 todo/.rtti-proof-pr-trigger2 diff --git a/todo/.rtti-proof-pr-trigger2 b/todo/.rtti-proof-pr-trigger2 new file mode 100644 index 000000000..5c33b151d --- /dev/null +++ b/todo/.rtti-proof-pr-trigger2 @@ -0,0 +1 @@ +trigger From 4329e9e8ad506d2992f0833aa0d08caedc4e38af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 21:40:25 +0000 Subject: [PATCH 098/370] emergent_testing: name the cross-realm proof after the defect it pins Review's point was narrow and right: the proof asserted `failed: 0` for a cross-realm promise, so the wrong behaviour was blessed by a green test. A gap recorded in a todo and a gap pinned by a passing assertion are different things, and the second reads as an endorsement. Renamed to `crossRealmPromiseSilentlyPasses`, so the failure mode appears in the suite output and in any report built from it, with the assertion unchanged and a comment saying it pins a defect. Measuring the second symptom turned up something worse than "reported as a pass": a cross-realm promise that *rejects* is never awaited, so its rejection is unhandled and Node's default takes the process down before the report is read. That is recorded rather than proven -- a test that kills the runner is not a test -- and it is itself a fact about the failure mode. The todo now carries all three options with what each costs, so the next person starts from the trade: leave it (both runners agree, the value is unreachable from authored FunctionalScript); subscribe with the intrinsic `then` (correct, ~150 lines in the path that executes every proof body); or refuse the value loudly, which needs a detector, and the only candidate -- `Object.prototype.toString` -- misses exactly the two spoof rows, so it would fail a reachable proof tree to catch an unreachable promise. Not fixed here either way: it is a change to the rule both runners share, so it belongs in the shared `sandbox`, not in one host. `fjs t` 3477/3477, `npx tsc` clean. --- fjs/emergent_testing/browser/proof.mjs | 31 +++++++++++--- .../todo/imports-promises-realms.md | 40 +++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index c62d7dc2c..22b91673c 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -260,14 +260,33 @@ export const proof = { assertEq(report.totals.failed, 1) assertEq(report.results[1]?.path, '.nested().child') }, - crossRealmPromiseIsWalkedAsATree: async () => { - const other = runInNewContext('({ resolve: value => Promise.resolve(value) })') - const report = await run({ + // **This pins a defect, not a desired behaviour.** The name says so on + // purpose: it appears in the suite output and in any report built from it, + // where a reader meets the failure mode rather than an assertion that reads + // like an endorsement. + // + // A rejected cross-realm promise is reported as a **pass**, and a resolved + // one's subtree disappears — a promise has no enumerable keys, so the tests + // inside it are never counted. `fjs t` does exactly the same, which is why + // it is not fixed here: it is a property of the shared rule, and one runner + // fixing it alone is the divergence this work exists to remove. See + // `../todo/imports-promises-realms.md`, which carries the options and what + // each costs. + crossRealmPromiseSilentlyPasses: async () => { + const other = runInNewContext('({ resolve: v => Promise.resolve(v) })') + const resolved = await run({ nested: () => other.resolve({ child: () => { throw 'boom' } }), }) - assertEq(report.totals.tests, 1) - assertEq(report.totals.failed, 0) - assertEq(report.results[0]?.path, '.nested') + // One test where there are two: the `child` inside the promise is never + // discovered. + assertEq(resolved.totals.tests, 1) + assertEq(resolved.totals.failed, 0) + assertEq(resolved.results[0]?.path, '.nested') + // A *rejected* cross-realm promise is the sharper symptom and cannot be + // proven here: never awaited, its rejection goes unhandled, and Node's + // default takes the process down before the report is even read. That + // is measured in `../todo/imports-promises-realms.md` rather than + // asserted, because a proof that kills the runner is not a proof. }, spoofedPromiseTag: async () => { const report = await run({ diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 8d0e95ade..9cd66ef98 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -253,6 +253,46 @@ brand check and the subscription, its `Reflect.apply` must sit outside a `new Promise` executor, and a throw from it must not be conflated with "not a promise". Reach for it then, not now. +### What the cross-realm gap actually costs, and the options + +Both runners walk a cross-realm promise as an ordinary value. Measured, the +consequences are not one symptom but two, and the second is worse than the +phrase "reported as a pass" suggests: + +| the proof returns | what happens | +| --- | --- | +| a cross-realm promise **resolving** to a sub-tree | reported `passed`; the tests inside it are never discovered, because a promise has no enumerable keys — one test where there are two | +| a cross-realm promise that **rejects** | never awaited, so the rejection is unhandled — under Node's default `--unhandled-rejections=throw` the **process dies before the report is read** | + +`crossRealmPromiseSilentlyPasses` pins the first. The second is deliberately not +a proof: a test that kills the runner is not a test, which is itself worth +knowing about this failure mode. + +Three ways to respond, with what each costs: + +- **Leave it.** Both runners agree, and the value is unreachable from authored + FunctionalScript — only an impure proof using `node:vm`, an iframe or a worker + can build one. This is the current state. Its price is the table above, and + a proof that names it. +- **Subscribe with the intrinsic `then`** (the deleted machinery). Correct on + every case. Its price is ~150 lines in the path that executes every proof + body, plus `speciesFails` and the shadow to tell "not a promise" from "promise + I cannot subscribe to". Belongs in the shared `sandbox` if taken, never in one + runner. +- **Refuse the value loudly** — report an unsupported cross-realm promise as a + failure rather than walking it. Cheaper than subscribing and *not free*: it + needs a detector, and the only candidate is `Object.prototype.toString`, which + this study measured at 5 of 7. Its two misses are exactly `spoofedPromiseTag` + and `frozenPromiseTag` — so a proof tree that carries a `then` key and sets + `Symbol.toStringTag: 'Promise'` would be failed instead of walked. That trades + a silent pass on an unreachable value for a false failure on a reachable one, + which is why it is recorded rather than done. + +Whichever is chosen, it is a change to the rule both runners share, so it lands +in the shared `sandbox` — step 4 and after in +[share the browser and console proof runners](share-browser-console-runner.md) — +and never in one host alone. + ### Known shared gap: a `Promise` subclass with an overridden `then` `await` adopts a promise's internal state only when its `constructor` is the From 0969f29694d5dce8f18c496a5bfd2de22cd1e3b1 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 14:40:53 -0700 Subject: [PATCH 099/370] chore: remove temporary RTTI doc workflow --- .github/workflows/zz-rtti-proof-doc.yml | 109 ------------------------ 1 file changed, 109 deletions(-) delete mode 100644 .github/workflows/zz-rtti-proof-doc.yml diff --git a/.github/workflows/zz-rtti-proof-doc.yml b/.github/workflows/zz-rtti-proof-doc.yml deleted file mode 100644 index 01140edab..000000000 --- a/.github/workflows/zz-rtti-proof-doc.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Apply RTTI proof design update - -on: - push: - branches: - - docs/rtti-proof-optimization - pull_request: - branches: - - main - -permissions: - contents: write - -jobs: - update: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: docs/rtti-proof-optimization - - name: Update RTTI type-system design - shell: python - run: | - from pathlib import Path - - path = Path('todo/rtti-type-system.md') - text = path.read_text() - marker = '#### 4. Scope: FunctionalScript files only' - if '#### 4. Proofs replace unchecked casts and enable specialization' in text: - raise SystemExit(0) - - section = r'''#### 4. Proofs replace unchecked casts and enable specialization - -This type system is **built on top of FunctionalScript; it is not part of the -FunctionalScript specification**. FunctionalScript itself does not know about -types. RTTI schemas, inference, assertions, and the checker are a higher-level -system whose programs are still FunctionalScript programs. - -Unlike TypeScript, the system should have no unchecked equivalent of `as`, -`as any`, double casts, or another way to override the checker without evidence. -A required property is established in one of two ways: - -1. **Static proof.** The compiler proves that the value satisfies the required - RTTI type or refinement. -2. **Runtime proof.** An assertion checks the property and narrows the value on - the successful branch. - -An assertion is a check, not a cast: if it fails, evaluation cannot continue on -the narrowed branch. This gives the system a simple invariant: every type claim -is backed by either a compile-time proof or a runtime check. - -Runtime proofs are also optimization facts. An assertion is ordinary -computation in the EDAG; if later analysis proves that it cannot fail, its -failure branch is dead and the check can be removed. A conservative compiler -may therefore leave more assertions at run time, while a stronger compiler can -remove them without weakening type safety. - -The same proofs can justify specialization. For example, proving that an input -to a SHA-256 implementation is a `bigint` that fits in 256 bits can replace -generic arbitrary-precision operations with fixed-width operations and enable -lowering to target-specific machine instructions. - -Matrices and tensors are the same story. Proofs may establish dimensions, -shapes, element types, layout, alignment, bounds, sparsity, or device placement. -Once known, generic FunctionalScript code can be transformed into fixed loops, -vector operations, tiled matrix operations, fused kernels, or code targeting -CPUs, GPUs, NPUs, and other accelerators. - -**Automatic differentiation is another transformation enabled by the same -model.** A pure computation represented as an EDAG can be transformed into a -new computation that calculates its derivatives. Reverse-mode automatic -differentiation can construct the backward graph used for neural-network -training: - -```text -x -> matmul -> relu -> matmul -> loss - | - v - differentiation - | - v - gradients of weights and x -``` - -The generated gradient graph is itself subject to the same proofs and -optimizations: known tensor shapes and element types can specialize gradient -operations, dead intermediates can disappear, forward and backward operations -can be fused, and the result can be lowered to GPUs or other specialized -hardware. - -The broader goal is that **domain-specific semantics should be expressible as -libraries, proofs, and program transformations rather than requiring a new -language for every domain**. FunctionalScript remains the small semantic -foundation; RTTI/refinement systems, tensor libraries, automatic -differentiation, and hardware lowering live on top of it. - -''' - if marker not in text: - raise SystemExit(f'marker not found: {marker}') - path.write_text(text.replace(marker, section + marker, 1)) - - name: Commit update - run: | - if git diff --quiet; then exit 0; fi - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add todo/rtti-type-system.md - git commit -m "docs: connect RTTI proofs to optimization" - git push From 0199860eff330eb6ba4a5fdc9712f59cb1f7c98b Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 14:41:03 -0700 Subject: [PATCH 100/370] chore: remove temporary RTTI doc trigger --- todo/.rtti-proof-pr-trigger | 1 - 1 file changed, 1 deletion(-) delete mode 100644 todo/.rtti-proof-pr-trigger diff --git a/todo/.rtti-proof-pr-trigger b/todo/.rtti-proof-pr-trigger deleted file mode 100644 index 4ce5b7361..000000000 --- a/todo/.rtti-proof-pr-trigger +++ /dev/null @@ -1 +0,0 @@ -trigger pull request workflow From c0a2a991677d57f7e6bb569edca0367e63ee5d91 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 14:41:07 -0700 Subject: [PATCH 101/370] chore: remove temporary RTTI doc trigger --- todo/.rtti-proof-pr-trigger2 | 1 - 1 file changed, 1 deletion(-) delete mode 100644 todo/.rtti-proof-pr-trigger2 diff --git a/todo/.rtti-proof-pr-trigger2 b/todo/.rtti-proof-pr-trigger2 deleted file mode 100644 index 5c33b151d..000000000 --- a/todo/.rtti-proof-pr-trigger2 +++ /dev/null @@ -1 +0,0 @@ -trigger From 48574b7bccca1d61d60bb0bd4d83abb71ca36a1c Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 14:41:12 -0700 Subject: [PATCH 102/370] chore: remove temporary RTTI doc trigger --- todo/.rtti-proof-update-trigger | 1 - 1 file changed, 1 deletion(-) delete mode 100644 todo/.rtti-proof-update-trigger diff --git a/todo/.rtti-proof-update-trigger b/todo/.rtti-proof-update-trigger deleted file mode 100644 index 5c33b151d..000000000 --- a/todo/.rtti-proof-update-trigger +++ /dev/null @@ -1 +0,0 @@ -trigger From e35b54e486017610efb7fd88642f5586ed917084 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 14:46:09 -0700 Subject: [PATCH 103/370] docs: connect RTTI proofs to optimization --- todo/rtti-type-system.md | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/todo/rtti-type-system.md b/todo/rtti-type-system.md index 0f9e220de..ab2059760 100644 --- a/todo/rtti-type-system.md +++ b/todo/rtti-type-system.md @@ -32,6 +32,11 @@ the thing that reads it. ### Proposal +**This type system is a layer on top of FunctionalScript, not part of the +FunctionalScript specification.** FunctionalScript itself remains type-agnostic; +RTTI schemas, inference, assertions, and checking are ordinary mechanisms that +a higher-level compiler or tool can interpret. + **RTTI is the single source of truth for both compile-time and run-time type verification of FunctionalScript.** One schema, written once, is what the compiler checks against, what `validate`/`parse` check against at run time, and @@ -447,6 +452,74 @@ which change what a generated declaration means, and rendering a schema is not the same as knowing which export carries it. See stage 1. It is also what lets a `.f.mjs` module drop its JSDoc without any consumer noticing. +### Proof-backed narrowing and specialization + +This system should have no unchecked equivalent of TypeScript's `as`, `as any`, +double casts, or another construct that simply tells the checker to trust a type +claim. A type or refinement claim must instead be established in one of two +ways: + +1. **Static proof.** The type-system compiler proves the property from the + program and the RTTI facts it already knows. +2. **Runtime proof.** An assertion checks the property and narrows the value on + the successful branch. If it fails, evaluation cannot continue on that + branch. + +The assertion is an ordinary FunctionalScript computation, not type syntax. The +important invariant is that every narrowing is backed by either a compile-time +proof or an executed runtime check; uncertainty becomes a proof obligation, not +an escape hatch. + +That does not imply permanent runtime overhead. Assertions become nodes in the +EDAG like other computations. If later analysis proves an assertion can never +fail, its failure branch is dead and the check can be removed. A conservative +compiler can therefore keep more checks at run time, while a stronger compiler +can remove them without weakening type safety. + +**The same proofs are optimization facts.** Once a property is established, the +compiler can specialize everything downstream that depends on it. For example, +proving that a value passed to a SHA-256 implementation is a `bigint` whose +value fits in 256 bits can replace generic arbitrary-precision operations with +fixed-width operations and enable lowering to target-specific instructions or +intrinsics. + +Matrices and tensors are the same pattern. Proofs may establish shape, +dimensions, element type, bounds, layout, alignment, sparsity, or device +placement. Those facts can turn generic FunctionalScript code into fixed loops, +vector operations, tiled matrix operations, fused kernels, or code targeting +CPUs, GPUs, NPUs, and other accelerators. + +**Automatic differentiation is another program transformation over the same +semantic foundation.** A pure computation represented as an EDAG can be +transformed into another computation that calculates its derivatives. +Reverse-mode automatic differentiation can construct the backward graph used +for neural-network training: + +```text +x -> matmul -> relu -> matmul -> loss + | + v + differentiation + | + v + gradients of weights and x +``` + +The generated gradient graph is itself subject to the same proofs and +optimizations: known tensor shapes and element types specialize gradient +operations, dead intermediates can disappear, forward and backward operations +can be fused, and the result can be lowered to GPUs or other specialized +hardware. + +The broader goal is that **domain-specific semantics live in libraries, proofs, +and program transformations rather than requiring a new language for every +domain**. FunctionalScript remains the small, type-agnostic semantic foundation; +RTTI/refinement systems, tensor libraries, automatic differentiation, and +hardware lowering live on top of it. This makes the same foundation suitable +for machine-learning programs without requiring application code to cross a +stack of specialized source languages merely to communicate facts the program +and its proofs already contain. + ### What a generated `.d.ts` can and cannot promise The epic's thesis is that one schema decides compile time and run time, so the From e3bd1ec7d1eabab51743aa03a0da6840f517ba0f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 21:54:58 +0000 Subject: [PATCH 104/370] Narrow the species claim, pin its other half, drop the todo tombstone Two findings from review, both correct. The changelog said `await` "ignores an overridden `then` and a custom `Symbol.species`", which is broader than the behaviour. Measured through `runBrowserProofs`: a valid species constructor is ignored and the subtree runs; a species getter that throws, and a species that is not a constructor, each fail while promise resolution reads them and are reported as that test's failure. The claim now says undiverted rather than immune, and names what happens in the failing cases -- which is the same outcome `fjs t` gives them, and better than losing the sub-tree silently. That distinction had no witness, which the earlier coverage review had also flagged: `customSpeciesThatFailsIsReported` restores what `species.proof.mjs` used to cover, pinning the throwing-species case as a reported failure at its own path. And `host-targeted-tests.md` was a tombstone. `todo/README.md` lists the allowed statuses -- open, wip, blocked, on-hold, irrelevant, won't fix -- and says a won't-fix issue documents its reason in a README, a code comment or another issue and is then deleted, with no status-only file left behind. The file said outright that there was no work item. Its reasoning moves into `browser-testing.md`, beside the `.f.mjs`-only scope rule it explains, and the file is gone. All references updated; no dangling links. `fjs t` 3478/3478, `npx tsc` clean. --- changelog/unreleased/1742.md | 6 ++- fjs/emergent_testing/browser.mjs | 9 +++- fjs/emergent_testing/browser/proof.mjs | 18 +++++++ fjs/emergent_testing/todo/browser-testing.md | 19 +++++-- .../todo/host-targeted-tests.md | 50 ------------------- .../todo/imports-promises-realms.md | 4 +- 6 files changed, 45 insertions(+), 61 deletions(-) delete mode 100644 fjs/emergent_testing/todo/host-targeted-tests.md diff --git a/changelog/unreleased/1742.md b/changelog/unreleased/1742.md index 51125790a..de285c65e 100644 --- a/changelog/unreleased/1742.md +++ b/changelog/unreleased/1742.md @@ -1,5 +1,7 @@ - **BREAKING CHANGES:** `emergent_testing`: the browser runner awaits only `instanceof Promise` values, as `fjs t` does. A promise from another realm is walked as a proof tree rather than awaited, the gap `fjs t` has had all along. - A same-realm promise is awaited, and `await` — unlike `then` — ignores an - overridden `then` and a custom `Symbol.species` + A same-realm promise is awaited, and `await` — unlike `then` — is not + diverted by the value's own `then` or by a custom `Symbol.species` that is a + valid constructor. A species that throws, or that is not a constructor, is + reported as that test's failure, as `fjs t` reports it diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index c3a8092c5..8c4015570 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -151,8 +151,13 @@ const runOne = (module, path, throws, fn, result) => { // would be a different operation: it calls the value's *own* `then`, and it // builds its answer through `constructor[Symbol.species]`, so a promise // carrying either can hand back something that is not its result. `await` - // on a same-realm promise adopts the promise's internal state and consults - // neither. + // is diverted by neither. + // + // It is not immune to them, which is a different claim: a species that + // throws, or that is not a constructor, fails while promise resolution + // reads it, and that failure is reported against the test that produced the + // value — the same outcome `fjs t` gives it, and better than losing the + // sub-tree in silence. // // The value is wrapped in a tuple first so that resolving it cannot // assimilate a proof tree carrying a `then` key: such a tree is a sub-tree diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 22b91673c..203593f72 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -260,6 +260,24 @@ export const proof = { assertEq(report.totals.failed, 1) assertEq(report.results[1]?.path, '.nested().child') }, + // The other half of the species story, and the half the deleted + // `species.proof.mjs` used to cover: `await` is not *immune* to a custom + // species, only undiverted by a valid one. A species that throws fails while + // promise resolution reads it, and that failure is attributed to the test + // that produced the promise rather than swallowed — which is what `fjs t` + // does with the same value. + customSpeciesThatFailsIsReported: async () => { + const constructor = {} + Object.defineProperty(constructor, Symbol.species, { + get: () => { throw new Error('species') }, + }) + const promised = Promise.resolve({ child: () => undefined }) + Object.defineProperty(promised, 'constructor', { value: constructor, configurable: true }) + const report = await run({ nested: () => promised }) + assertEq(report.totals.failed, 1) + assertEq(report.results[0]?.path, '.nested') + assertEq(report.results[0]?.message, 'species') + }, // **This pins a defect, not a desired behaviour.** The name says so on // purpose: it appears in the suite output and in any report built from it, // where a reader meets the failure mode rather than an assertion that reads diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index 3c5947ada..7100e54be 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -76,9 +76,20 @@ pure — no host objects, no `node:` imports, no promises, no `async` — so a `.f.mjs` proof means the same thing in every runner, and the extension is a sufficient declaration for a static selector that never imports anything. An impure `.mjs` proof means whatever its host provides: `node:fs`, `node:vm`, -`process`, `node:test`, a filesystem, a subprocess. Loading those into a page and -expecting them to test anything is not a goal — see -[impure `.mjs` proofs are Node-only](host-targeted-tests.md). +`process`, `node:test`, a filesystem, a subprocess. + +**Impure `.mjs` proofs are therefore Node-only, by construction, and that is the +answer rather than a gap.** Loading JavaScript written against Node into a +browser and expecting it to test anything is a nightmare, and nobody has asked +for it; no convention for labelling a test's host changes what `node:fs` needs. +There is no work item here, and the rule is recorded because it looks like an +omission if met without context. Two things a future design would have to face, +if someone ever turns up with a concrete impure test a browser must run: +*targeting* and *describing* are different questions — `browser/proof.mjs` tests +browser code but runs in Node, so a filename convention would mislabel exactly +that file — and a declaration is a claim, so a test declaring `browser` while +importing `node:fs` is a lie the dependency-graph acceptance above has to +catch. Two things follow that are easy to get wrong: @@ -193,7 +204,5 @@ workers, or visual regression testing. - [`.f.mjs` proof discovery and coverage](f-mjs-test-and-coverage.md) - [Shared browser/console runner core](share-browser-console-runner.md) - [Explicit browser test controls](browser-test-controls.md) -- [Impure `.mjs` proofs are Node-only](host-targeted-tests.md) — why the - `.f.mjs`-only selection rule is the answer rather than a gap - [authored `.f.mjs` package support](../../ci/todo/f-mjs-package-support.md) - [project roadmap](../../../todo/plan/roadmap.md) diff --git a/fjs/emergent_testing/todo/host-targeted-tests.md b/fjs/emergent_testing/todo/host-targeted-tests.md deleted file mode 100644 index a97baca1d..000000000 --- a/fjs/emergent_testing/todo/host-targeted-tests.md +++ /dev/null @@ -1,50 +0,0 @@ -## Impure `.mjs` proofs are Node-only, and that is the answer - -**Priority:** P5 -**Status:** not planned — recorded so it is not rediscovered as a gap - -### The decision - -**The browser runs authored FunctionalScript and nothing else.** -`website/browser-prepare.mjs` selects on `name.endsWith('.f.mjs')`, the generated -manifest carries 137 such modules, and impure `.mjs` proofs are excluded by -construction. That is correct behaviour, not a limitation. - -Loading JavaScript written against Node into a browser and expecting it to test -anything is a nightmare, and nobody has asked for it. A Node proof reaches for -`node:fs`, `node:vm`, `process`, `node:test`, a filesystem and a subprocess — a -page has none of them, and no convention for labelling tests changes that. The -promise question that led here is the smallest visible corner of it. - -**So there is no work item here.** This file exists because the reasoning is -worth keeping: the `.f.mjs`-only rule looks like an omission if you meet it -without context, and someone will otherwise decide it needs fixing. - -### Why `.f.mjs` needs no convention - -Authored FunctionalScript is pure — no host objects, no `node:` imports, no -promises — so a `.f.mjs` proof means the same thing in `fjs t`, in a browser, -and in any runner added later. The extension *is* the declaration. That is what -lets the browser select statically, without importing anything, and be right. - -### If it ever comes up - -Only if someone has a concrete impure test they want a browser to run, and can -say why it cannot be written as `.f.mjs`. Two things a design would then have to -face, both easy to miss: - -- **Targeting and describing are different questions.** - `emergent_testing/browser/proof.mjs` *tests* browser code but *runs* in Node, - against the browser runner called as a library with a DOM stand-in. A filename - convention that conflates the two would mislabel exactly that file. -- **A declaration is a claim, and claims need checking.** A test declaring - `browser` while importing `node:fs` is a lie the preparation program has to - catch — the dependency-graph acceptance - [browser testing](browser-testing.md) already specifies. - -### Related - -- [Run FunctionalScript proofs inside real browsers](browser-testing.md) — the - `.f.mjs` selection rule this records the reasoning for. -- [Imports, promises and realms](imports-promises-realms.md) — why that rule - makes the browser's promise machinery unnecessary. diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 9cd66ef98..6fb8daf06 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -245,8 +245,8 @@ subscription protocol for a threat model that does not exist here. **If proofs ever run in iframes or workers** — which [browser testing](browser-testing.md) contemplates and nothing does today — a cross-realm promise becomes reachable for the first time. (Running impure `.mjs` -proofs in a browser is the other way it could happen, and is -[not planned](host-targeted-tests.md).) That is the moment to +proofs in a browser is the other way it could happen, and +[browser testing](browser-testing.md) records why that is not a goal.) That is the moment to revisit this, with a real case in hand rather than a constructed one, and the material is preserved above: the intrinsic `Promise.prototype.then` is both the brand check and the subscription, its `Reflect.apply` must sit outside a `new From 0014f203e125f9d32b17a48ffb5678c73335ff97 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 22:00:54 +0000 Subject: [PATCH 105/370] todo: use the statuses `todo/README.md` actually defines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `imports-promises-realms.md` carried an invented hybrid -- "closed for the runner; open only as a note for iframes and workers" -- where the README defines exactly six values: open, wip, blocked, on-hold, irrelevant, won't fix. "closed" is not among them, and a two-part status cannot be read consistently by anything scanning the tracker. It is `on-hold`, which the README glosses as intentionally deferred and is what the file describes: the investigation is finished, its decision is implemented, and the remainder waits on proofs running in iframes or workers, which nothing does. The Outcome section now says that in words so the one-word status is not carrying the whole meaning. Also drops the "— migration debt" suffix from `browser-runner-functional-script.md`; 246 of the repository's statuses are a bare word, and the qualifier belongs in the body, where it now is. This is the second invented status in this branch, one file away from the tombstone with the same cause: writing a status that reads well instead of one the tracker defines. --- .../todo/browser-runner-functional-script.md | 5 ++++- fjs/emergent_testing/todo/imports-promises-realms.md | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/fjs/emergent_testing/todo/browser-runner-functional-script.md b/fjs/emergent_testing/todo/browser-runner-functional-script.md index 727e0ee8f..b66ca3275 100644 --- a/fjs/emergent_testing/todo/browser-runner-functional-script.md +++ b/fjs/emergent_testing/todo/browser-runner-functional-script.md @@ -1,10 +1,13 @@ ## Move the browser runner's business logic to FunctionalScript **Priority:** P2 -**Status:** open — migration debt +**Status:** open ### Problem +This is migration debt under the rule that business logic belongs in `.f.mjs`, +not a new proposal. + [`browser.mjs`](../browser.mjs) is a plain `.mjs` file holding a whole test runner. Under the repository rule that business logic belongs in `.f.mjs` and plain `.mjs` is a thin host boundary, most of it is in the wrong place. diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 6fb8daf06..3d56ec070 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -1,7 +1,7 @@ ## Investigate imports, promises and realms **Priority:** P3 -**Status:** closed for the runner; open only as a note for iframes and workers +**Status:** on-hold ### Problem @@ -207,6 +207,12 @@ structural rule hold. ### Outcome +**The investigation is finished and its decision is implemented; what is left is +deferred, which is what `on-hold` above means.** Nothing here is waiting on a +person: it is waiting on proofs running in iframes or workers, which nothing +does today. The file stays because the measurements below are the input to that +decision when it arrives, and re-deriving them cost several review rounds. + **Done.** The browser's `sandbox` decides with `p instanceof Promise` and then **`await`s** — exactly as `fjs t` does, and the `await` is the load-bearing half. `value.then(a, b)` is a different operation: it calls the value's own `then` and From b3fad20b8122294143203d7198055ff644097a1c Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 15:02:33 -0700 Subject: [PATCH 106/370] docs: clarify RTTI proof constraints --- todo/rtti-type-system.md | 72 +++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/todo/rtti-type-system.md b/todo/rtti-type-system.md index ab2059760..1398c8887 100644 --- a/todo/rtti-type-system.md +++ b/todo/rtti-type-system.md @@ -33,9 +33,9 @@ the thing that reads it. ### Proposal **This type system is a layer on top of FunctionalScript, not part of the -FunctionalScript specification.** FunctionalScript itself remains type-agnostic; -RTTI schemas, inference, assertions, and checking are ordinary mechanisms that -a higher-level compiler or tool can interpret. +FunctionalScript specification.** FunctionalScript remains type-agnostic: +`//:` and `/*: */` are ordinary comments to the language; the RTTI layer gives +them meaning using the comment/token stream exposed by the toolchain. **RTTI is the single source of truth for both compile-time and run-time type verification of FunctionalScript.** One schema, written once, is what the @@ -226,11 +226,10 @@ That is what a conversion should lean on — a claim about the two declarations not on both checkers happening to accept the value in front of them. Stage 11 carries it. -[type-annotations](../spec/todo/3360-type-annotations.md) reaches the same -conclusion — "`/*: … */` and JSDoc's `/** … */` coexist while the tree -migrates" — and is the spec-side half of this epic. It states the body as an -expression; **this epic narrows it to a name**, and stage 2 is where that -narrowing lands in the spec. +[type-annotations](../spec/todo/3360-type-annotations.md) is the design note for +this annotation convention despite living under `spec/todo`; it does not add +type semantics to FunctionalScript. It states the body as an expression; **this +epic narrows it to a name**, and stage 2 is where that narrowing lands. #### 3. Every RTTI type is immutable, and that is what makes it sound @@ -470,18 +469,17 @@ important invariant is that every narrowing is backed by either a compile-time proof or an executed runtime check; uncertainty becomes a proof obligation, not an escape hatch. -That does not imply permanent runtime overhead. Assertions become nodes in the -EDAG like other computations. If later analysis proves an assertion can never -fail, its failure branch is dead and the check can be removed. A conservative -compiler can therefore keep more checks at run time, while a stronger compiler -can remove them without weakening type safety. +That does not imply permanent runtime overhead. Assertions are ordinary EDAG +computations. If the compiler proves an assertion is total and always succeeds, +it may remove it; proving only that the failure branch is unreachable is not +enough. A conservative compiler can keep more checks while a stronger one +removes them without weakening type safety. -**The same proofs are optimization facts.** Once a property is established, the -compiler can specialize everything downstream that depends on it. For example, -proving that a value passed to a SHA-256 implementation is a `bigint` whose -value fits in 256 bits can replace generic arbitrary-precision operations with -fixed-width operations and enable lowering to target-specific instructions or -intrinsics. +**The same proofs are optimization facts.** They can justify specialization +where the compiler proves the specialized operation equivalent to the original. +For example, proving a SHA-256 value and the relevant intermediate operations +fit fixed widths (or have explicit modular semantics) can replace generic +`bigint` operations with target-specific fixed-width instructions or intrinsics. Matrices and tensors are the same pattern. Proofs may establish shape, dimensions, element type, bounds, layout, alignment, sparsity, or device @@ -489,11 +487,11 @@ placement. Those facts can turn generic FunctionalScript code into fixed loops, vector operations, tiled matrix operations, fused kernels, or code targeting CPUs, GPUs, NPUs, and other accelerators. -**Automatic differentiation is another program transformation over the same -semantic foundation.** A pure computation represented as an EDAG can be -transformed into another computation that calculates its derivatives. -Reverse-mode automatic differentiation can construct the backward graph used -for neural-network training: +**Automatic differentiation is another possible transformation over the same +semantic foundation.** For an EDAG subset whose primitives have defined +derivative rules, including their behavior at nondifferentiable points, +reverse-mode differentiation can construct the backward graph used for +neural-network training; unsupported operations are outside that transform. ```text x -> matmul -> relu -> matmul -> loss @@ -631,7 +629,7 @@ it as scoped to the object shapes TypeScript can name. | Canonical data form, `subset` | [`data/`](../fjs/rtti/data/module.f.mjs) | done, and **sound but deliberately incomplete** — it never answers `true` for a non-inclusion, and may answer `false` for one that holds only semantically. The primitive a checker needs, not the whole of assignability | | TypeScript emission | [`ts/module.f.mjs`](../fjs/rtti/ts/module.f.mjs) | done as a printer — but it and `Ts<>` disagree on `unknown` and on tuple openness, by its own doc comment, so it is not yet a faithful `.d.ts` generator | | Compile-time bridge | `Ts` in [`ts/types.ts`](../fjs/rtti/ts/types.ts) | done, and transitional — see Problem | -| Annotation syntax | — | not started | +| Annotation convention | — | not started | | Compile-time evaluation | [`fjs/fsc/todo/47.md`](../fjs/fsc/todo/47.md) | not started | | Inference | [type inference](../spec/todo/3370-type-inference.md) | not started — most of the work | | Function schemas | [668-rtti-function-types](../fjs/rtti/todo/668-rtti-function-types.md) | not started — and **nearly half** the tree's JSDoc type bodies are function types (~46% when measured in review of #1719; counts drift, so re-measure rather than cite this), so it gates a large share of stage 11 | @@ -818,8 +816,8 @@ so that issue's open question is answered yes by this stage. - **A type grammar.** Not a subset of TypeScript's type expressions, not a JSDoc dialect, not a new one. Commitment 1 is the whole point of the epic. -- **New syntax beyond the two comment forms.** `//:` and `/*: */` are the entire - surface area added to the language. +- **New FunctionalScript syntax.** `//:` and `/*: */` remain ordinary comments; + only the RTTI layer interprets them. - **Expressions inside an annotation.** Not even the language's own: the body is one name. A comment that can hold a call can hold a sub-language, and that is the road back to a type grammar. Give the type a `const` and use its name. @@ -1007,11 +1005,12 @@ are stated instead: name in [type-annotations](../spec/todo/3360-type-annotations.md), and settle which positions accept an annotation — `const`, parameter, return, export — and what the line form attaches to. -- [ ] **3. Recognize `//:` and `/*: */` in the parser** and resolve the one - identifier in the body against the module's bindings — under the scope - rule stage 2 settles per [open question 2](#open-questions), since - module-scope-only and "anything reducible" are different lookups. A distinct token - kind is cleaner than inspecting the body's first character; neither adds a +- [ ] **3. Recognize `//:` and `/*: */` in the RTTI type-system frontend** from + the tokenizer/parser comment stream and resolve the one identifier in the + body against the module's bindings — under the scope rule stage 2 settles + per [open question 2](#open-questions), since module-scope-only and + "anything reducible" are different lookups. A distinct token kind is + cleaner than inspecting the body's first character; neither adds a grammar, and neither needs the expression parser. - [ ] **4. Evaluate an annotation at compile time** ([`fjs/fsc/todo/47.md`](../fjs/fsc/todo/47.md)) — the binding the name @@ -1724,11 +1723,10 @@ splits around inference, so the runnable order is 668's representation half [the gates](#tasks). Listed here by subject rather than by schedule. - [type-annotations](../spec/todo/3360-type-annotations.md) — the annotation - form, the parser consequences, and the argument for why there is no type - grammar. The spec-side statement of commitments 1 and 2; stages 2–5 land - there. It states the annotation body as an ordinary expression handed to the - expression parser; **this epic narrows it to a name**, and stage 2 is that - edit. + form, the tooling consequences, and the argument for why there is no type + grammar. The design note for commitments 1 and 2; stages 2–5 land there. It + states the annotation body as an ordinary expression handed to the expression + parser; **this epic narrows it to a name**, and stage 2 is that edit. - [type inference](../spec/todo/3370-type-inference.md) — annotations are only as useful as what can be inferred without them. Stage 6. - [668-rtti-function-types](../fjs/rtti/todo/668-rtti-function-types.md) — From fce05ab1f60a4d0f99a230d270a7fcefb7f853d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 22:15:00 +0000 Subject: [PATCH 107/370] emergent_testing: settle promises through the intrinsic `then` again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REVIEW.md says a regression is never deferrable, and I had deferred one. Measured against main rather than argued: a promise whose `constructor` has been replaced and whose `then` is overridden -- `class Sub extends Promise { then() {} }` -- gives "2 tests, failed 0" on main and HUNG on this branch. `await` adopts a promise's internal state only when its constructor is the intrinsic `Promise`; otherwise resolution assimilates the value through the `then` it overrode, and nothing settles. A hang is also the other never-deferrable outcome, silence: no report at all. Restores the subscription -- `Reflect.apply(Promise.prototype.then, …)`, with the apply outside the executor so a failing species propagates instead of rejecting the wrapper. About fifteen lines, and not the machinery this branch deleted: `speciesFails`, the `constructor` shadow and its retry stay gone, because those *recover* a hostile species rather than subscribe to a promise. Measured through `runBrowserProofs` afterwards: plain resolve, own `then` override, replaced constructor with override, and a valid custom species all run their subtree; a throwing species is reported as that test's failure; a `{ then }` proof tree is still walked as a tree. `promiseWithReplacedConstructorStillSettles` is the regression guard. The two `awaitIgnores*` proofs are renamed `subscriptionIgnores*`, since the mechanism they pin is no longer `await`, and the changelog and comments now describe the settlement path rather than claiming `await` does the work. `fjs t` still hangs on the same value. That is older than this work, so it is deferred behind a note in `todo/imports-promises-realms.md` that names it as owed, and it is one line once the settlement path is shared. `fjs t` 3479/3479, `npx tsc` clean. --- changelog/unreleased/1742.md | 8 +-- fjs/emergent_testing/browser.mjs | 59 ++++++++++++++----- fjs/emergent_testing/browser/proof.mjs | 34 ++++++++--- .../todo/imports-promises-realms.md | 49 ++++++++------- 4 files changed, 98 insertions(+), 52 deletions(-) diff --git a/changelog/unreleased/1742.md b/changelog/unreleased/1742.md index de285c65e..3380ea114 100644 --- a/changelog/unreleased/1742.md +++ b/changelog/unreleased/1742.md @@ -1,7 +1,7 @@ - **BREAKING CHANGES:** `emergent_testing`: the browser runner awaits only `instanceof Promise` values, as `fjs t` does. A promise from another realm is walked as a proof tree rather than awaited, the gap `fjs t` has had all along. - A same-realm promise is awaited, and `await` — unlike `then` — is not - diverted by the value's own `then` or by a custom `Symbol.species` that is a - valid constructor. A species that throws, or that is not a constructor, is - reported as that test's failure, as `fjs t` reports it + A same-realm promise is settled through the intrinsic `Promise.prototype.then`, + so the value's own `then` and a custom `Symbol.species` cannot divert it, and + a promise whose `constructor` has been replaced still settles. A species that + throws, or that is not a constructor, is reported as that test's failure diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 8c4015570..b2d26b0db 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -74,6 +74,36 @@ const errorDetails = error => { /** @typedef {{ readonly status: string, readonly browser: string, readonly totals: { readonly tests: number, readonly passed: number, readonly failed: number }, readonly duration: number, readonly results: readonly _BrowserTestResult[] }} BrowserTestReport */ +/** + * Waits for a promise the way `await` does when it can — by the promise's own + * settlement, not by anything the value can replace. + * + * `await` is not always that. It adopts a promise's internal state only when the + * value's `constructor` is the intrinsic `Promise`; otherwise resolution + * assimilates it by calling its `then`, so a `Promise` subclass — or a promise + * whose `constructor` has been replaced — that also overrides `then` never + * settles, and the run hangs with no report. The intrinsic `then`, applied + * directly, ignores the override. + * + * **The `Reflect.apply` is outside the executor on purpose.** A throw inside a + * `new Promise` executor rejects that promise instead of propagating, and this + * one has to propagate: a promise whose `constructor[Symbol.species]` fails + * throws here, and the caller reports it against the test that produced the + * value — which is what `fjs t` does with it too. + * + * @type {(value: Promise) => Promise} + */ +const intrinsicSubscribe = value => { + /** @type {(v: unknown) => void} */ + let ok = () => undefined + /** @type {(e: unknown) => void} */ + let no = () => undefined + /** @type {Promise} */ + const settled = new Promise((resolve, reject) => { ok = resolve; no = reject }) + Reflect.apply(Promise.prototype.then, value, [ok, no]) + return settled +} + /** * A failure of a whole module — one that will not link, or whose `proof` export * cannot be enumerated. It does not go through `testResult`, and that is the @@ -146,18 +176,17 @@ const runOne = (module, path, throws, fn, result) => { result(failure) return [failure] } - // `instanceof Promise` and then `await`, which is exactly what `fjs t`'s - // `sandbox` does — and the `await` is not incidental. `value.then(a, b)` - // would be a different operation: it calls the value's *own* `then`, and it - // builds its answer through `constructor[Symbol.species]`, so a promise - // carrying either can hand back something that is not its result. `await` - // is diverted by neither. + // `instanceof Promise` decides, exactly as `fjs t`'s `sandbox` decides. + // Settlement then goes through the intrinsic `then` rather than `await`, + // for the reason `intrinsicSubscribe` gives: `await` adopts internal state + // only for a promise whose `constructor` is the intrinsic `Promise`, and + // assimilates the rest through a `then` the value can replace. // - // It is not immune to them, which is a different claim: a species that - // throws, or that is not a constructor, fails while promise resolution - // reads it, and that failure is reported against the test that produced the - // value — the same outcome `fjs t` gives it, and better than losing the - // sub-tree in silence. + // Neither the value's own `then` nor its `constructor[Symbol.species]` can + // divert the result. That is not immunity: a species that throws, or that + // is not a constructor, fails while the intrinsic `then` reads it, and the + // failure is reported against the test that produced the value — the same + // outcome `fjs t` gives it, and better than losing the sub-tree in silence. // // The value is wrapped in a tuple first so that resolving it cannot // assimilate a proof tree carrying a `then` key: such a tree is a sub-tree @@ -187,11 +216,11 @@ const runOne = (module, path, throws, fn, result) => { if (!isPromise) { return passed(value) } /** @type {readonly [unknown]} */ let resolved - // Only the `await` is guarded. A throw from `passed` is the traversal's - // own and has its own handling; catching it here would report a broken - // proof tree as a rejected promise. + // Only the subscription is guarded. A throw from `passed` is the + // traversal's own and has its own handling; catching it here would + // report a broken proof tree as a rejected promise. try { - resolved = [await value] + resolved = [await intrinsicSubscribe(/** @type {Promise} */ (value))] } catch (error) { return failed(error) } diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 203593f72..2fa71ebad 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -229,12 +229,13 @@ export const proof = { assertEq(report.results[0]?.path, '.nested') assertEq(report.results[0]?.message, 'trap') }, - // `await`, not `value.then(...)`. A promise can replace its own `then`, and - // it can make `constructor[Symbol.species]` build something that is not a - // promise at all; `.then` consults both, `await` consults neither and reads - // the promise's internal state. These two pin that the browser awaits the - // way `fjs t` does rather than merely checking the same brand. - awaitIgnoresAnOwnThenOverride: async () => { + // A promise can replace its own `then`, and it can make + // `constructor[Symbol.species]` build something that is not a promise at + // all. `value.then(...)` consults both. The runner subscribes with the + // *intrinsic* `then`, which consults neither — and which, unlike `await`, + // works even when the value's `constructor` is not the intrinsic `Promise`. + // These three pin the settlement path. + subscriptionIgnoresAnOwnThenOverride: async () => { const promised = Promise.resolve({ child: () => undefined }) // A no-op override: anything that calls it instead of awaiting gets // `undefined` and loses the subtree. @@ -243,7 +244,7 @@ export const proof = { assertEq(report.totals.tests, 2) assertEq(report.results[1]?.path, '.nested().child') }, - awaitIgnoresACustomSpecies: async () => { + subscriptionIgnoresACustomSpecies: async () => { // `then` builds its answer through `constructor[Symbol.species]`, and // this one returns an ordinary object, so `.then` would hand back a // non-promise before the proof had settled. @@ -260,6 +261,25 @@ export const proof = { assertEq(report.totals.failed, 1) assertEq(report.results[1]?.path, '.nested().child') }, + // The case plain `await` cannot handle, reached most naturally by + // `class Sub extends Promise { then() {} }`: `await` adopts a promise's + // internal state only when its `constructor` is the intrinsic `Promise`, + // and otherwise assimilates it through `then` — so a no-op override never + // settles and the run hangs with no report at all. + // + // This is a regression guard. The runner handled it before this change; an + // intermediate version of this change, which used plain `await`, did not. + promiseWithReplacedConstructorStillSettles: async () => { + // Built by hand rather than with `class extends`: the point is a value + // whose `constructor` is not the intrinsic `Promise` and whose `then` is + // overridden, which is what makes `await` assimilate instead of adopt. + const promised = Promise.resolve({ child: () => undefined }) + Object.defineProperty(promised, 'constructor', { value: function Sub() {} }) + Object.defineProperty(promised, 'then', { value: () => undefined }) + const report = await run({ nested: () => promised }) + assertEq(report.totals.tests, 2) + assertEq(report.results[1]?.path, '.nested().child') + }, // The other half of the species story, and the half the deleted // `species.proof.mjs` used to cover: `await` is not *immune* to a custom // species, only undiverted by a valid one. A species that throws fails while diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 3d56ec070..2fc4aadb4 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -299,38 +299,35 @@ in the shared `sandbox` — step 4 and after in [share the browser and console proof runners](share-browser-console-runner.md) — and never in one host alone. -### Known shared gap: a `Promise` subclass with an overridden `then` +### `fjs t` still hangs on a promise whose `constructor` was replaced `await` adopts a promise's internal state only when its `constructor` is the -intrinsic `Promise`. For a subclass — or a native promise whose `constructor` -has been replaced — resolution assimilates the value by calling its `then` -instead, so a no-op override never settles and the run hangs: +intrinsic `Promise`. Otherwise resolution assimilates the value by calling its +`then`, so a `Promise` subclass — or any promise whose `constructor` has been +replaced — that also overrides `then` never settles: | value | `fjs t` | browser | | --- | --- | --- | -| `class Sub extends Promise { then() {} }`, resolved | **HUNG** | **HUNG** | - -Measured; both runners, identically, because both decide with `instanceof -Promise` and then `await`. That sameness is the point: it is a property of the -shared rule, not a browser regression, and it is recorded here rather than -patched in one host. - -**Not fixed, deliberately.** The intrinsic-`then` subscription described above -would fix it, and reintroducing that machinery to defend a value the runner -cannot meet is the trade this issue already rejected: authored FunctionalScript -has no `Promise`, no `class`, and no `extends`, so only an impure `.mjs` proof -can construct this — the same category as the fixtures deleted with the -machinery. - -It is also worth being clear about what it is a special case of. **Any** proof +| `class Sub extends Promise { then() {} }`, resolved | **HUNG** | settles | + +The browser subscribes with the intrinsic `Promise.prototype.then` rather than +`await`, which ignores the override — about fifteen lines, and not the machinery +this issue deleted: `speciesFails`, the `constructor` shadow and its retry are +still gone, because those *recover* a hostile species rather than subscribe. +`promiseWithReplacedConstructorStillSettles` pins it. + +**`fjs t` owes the same fix**, and it is one line in `effects/node/module.mjs`'s +`sandbox` once the settlement path is shared — step 4 and after in +[share the browser and console proof runners](share-browser-console-runner.md). +Until then the two differ, which is a difference with a written reason: the +browser had this behaviour before the deletion, losing it was a regression, and +a regression is not deferrable. `fjs t`'s hang is older than this work and is +deferred behind this note. + +It is also worth knowing what neither runner can fix here: **any** proof returning a promise that never settles hangs any runner — -`() => new Promise(() => {})` needs no subclass and no override. A runner that -survived the subclass case would still hang on that one. Bounding a proof's -running time is the general answer, and it is not this issue. - -If the day comes that a proof legitimately returns a `Promise` subclass, the -material for the fix is above, and the fix belongs in the shared `sandbox` so -both runners get it at once. +`() => new Promise(() => {})` needs no subclass and no override. Bounding a +proof's running time is the general answer and is not this issue. ### Constraints From 5acbcc33e832c160ecb08763f11b14a98d07ca19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 22:15:55 +0000 Subject: [PATCH 108/370] todo: name the cross-realm refusal as a debt, not one option of three REVIEW.md: an unsupported input is refused, never answered with a plausible wrong value. A cross-realm promise reported as `passed` is exactly that, so refusing it is what this owes -- and writing the three responses as a menu let the current behaviour read as a choice rather than a deferral. The options stay, because what each costs is the useful part, but they are now framed as ways to pay the debt. "Leave it" is marked as a deferral rather than an answer, and "refuse loudly" is marked as the shape of the fix that lacks a detector: `Object.prototype.toString` is 5 of 7 and misses exactly the two spoof rows, so refusing on it would fail a reachable proof tree to refuse an unreachable promise. Finding a detector that does not do that is the actual open work. Deferred, not dismissed: both runners have always behaved this way, so it is a pre-existing defect rather than a regression, and the fix changes a rule the two share, so it belongs in the shared `sandbox`. --- .../todo/imports-promises-realms.md | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 2fc4aadb4..02d296147 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -274,12 +274,20 @@ phrase "reported as a pass" suggests: a proof: a test that kills the runner is not a test, which is itself worth knowing about this failure mode. -Three ways to respond, with what each costs: +**This is a debt, not a menu.** [REVIEW.md](../../../REVIEW.md) says an +unsupported input is refused, never answered with a plausible wrong value, and a +cross-realm promise reported as `passed` is exactly that. So refusal is what +this owes; the entries below are what it would cost to pay it, and the reason it +is deferred rather than done is that the only available detector is measurably +worse than the defect. It is deferred as a pre-existing defect — both runners +have always behaved this way — and it is not deferrable indefinitely. + +Three ways to pay it, with what each costs: - **Leave it.** Both runners agree, and the value is unreachable from authored FunctionalScript — only an impure proof using `node:vm`, an iframe or a worker - can build one. This is the current state. Its price is the table above, and - a proof that names it. + can build one. This is the current state, and it is a deferral rather than an + answer. Its price is the table above, and a proof that names it. - **Subscribe with the intrinsic `then`** (the deleted machinery). Correct on every case. Its price is ~150 lines in the path that executes every proof body, plus `speciesFails` and the shadow to tell "not a promise" from "promise @@ -291,8 +299,10 @@ Three ways to respond, with what each costs: this study measured at 5 of 7. Its two misses are exactly `spoofedPromiseTag` and `frozenPromiseTag` — so a proof tree that carries a `then` key and sets `Symbol.toStringTag: 'Promise'` would be failed instead of walked. That trades - a silent pass on an unreachable value for a false failure on a reachable one, - which is why it is recorded rather than done. + a silent pass on an unreachable value for a false failure on a reachable one. + A refusal that fires on valid input is not refusing loudly; it is a new wrong + answer with a louder voice. **This is the shape of the fix, and it needs a + detector that does not exist yet** — finding one is the actual open work here. Whichever is chosen, it is a change to the rule both runners share, so it lands in the shared `sandbox` — step 4 and after in From 9b7c2838fc3771bc9a0299ff9599e676d47fd66d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 22:40:12 +0000 Subject: [PATCH 109/370] emergent_testing: do not assimilate a fulfilled proof tree The subscription I added resolved its wrapper with the fulfilled value directly, which reintroduces the assimilation the tuple wrapper around `fn()` exists to prevent -- one layer down. A promise fulfilled with a proof tree that carries a zero-argument `then` test is a thenable to `resolve`, which calls it as a resolver and waits for a settlement that never comes: the run hangs with no report. The subscription now answers a tuple, so the tree reaches the traversal as data. `fulfilledProofTreeWithAThenTestIsWalked` pins it, and the branch matches main again on that value: 3 tests, none failed. Note the fixture has to add `then` *after* the promise fulfils. `Promise.resolve({ then })` never settles in any implementation, with no runner involved, so a fixture written that way measures JavaScript rather than this module -- which is what my first attempt at reproducing it did. Also fixes two things in `todo/browser-testing.md` found in the same review. The claim "FunctionalScript cannot produce a promise, so nothing the browser executes can be one" survived here -- a seventh instance of a sentence I had reported as corrected everywhere -- and describing the handling as unnecessary invites a later change to delete it. It is a guard required by an unenforced selector, and now says so. And the older "extending selection to generic `.mjs` is optional, later" contradicted the new scope section; the scope section wins, and the paragraph now says what remains open instead: rejecting a `.f.mjs` whose graph reaches `node:`. `fjs t` 3480/3480, `npx tsc` clean. --- fjs/emergent_testing/browser.mjs | 19 ++++++++++++----- fjs/emergent_testing/browser/proof.mjs | 22 ++++++++++++++++++++ fjs/emergent_testing/todo/browser-testing.md | 21 ++++++++++++------- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index b2d26b0db..e0ef04f31 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -91,16 +91,25 @@ const errorDetails = error => { * throws here, and the caller reports it against the test that produced the * value — which is what `fjs t` does with it too. * - * @type {(value: Promise) => Promise} + * **The answer is a tuple, for the same reason `fn()`'s is.** Resolving the + * wrapper with the fulfilled value directly would assimilate it: a proof tree + * carrying a zero-argument `then` test is a thenable to `resolve`, which would + * call it as a resolver and wait for a settlement that never comes. The tree + * has to reach the traversal as data, so it travels wrapped. + * + * @type {(value: Promise) => Promise} */ const intrinsicSubscribe = value => { - /** @type {(v: unknown) => void} */ + /** @type {(v: readonly [unknown]) => void} */ let ok = () => undefined /** @type {(e: unknown) => void} */ let no = () => undefined - /** @type {Promise} */ + /** @type {Promise} */ const settled = new Promise((resolve, reject) => { ok = resolve; no = reject }) - Reflect.apply(Promise.prototype.then, value, [ok, no]) + Reflect.apply(Promise.prototype.then, value, [ + /** @type {(v: unknown) => void} */ (v => ok([v])), + no, + ]) return settled } @@ -220,7 +229,7 @@ const runOne = (module, path, throws, fn, result) => { // traversal's own and has its own handling; catching it here would // report a broken proof tree as a rejected promise. try { - resolved = [await intrinsicSubscribe(/** @type {Promise} */ (value))] + resolved = await intrinsicSubscribe(/** @type {Promise} */ (value)) } catch (error) { return failed(error) } diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 2fa71ebad..e529fa769 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -261,6 +261,28 @@ export const proof = { assertEq(report.totals.failed, 1) assertEq(report.results[1]?.path, '.nested().child') }, + // A fulfilled value that is *itself* a proof tree with a `then` test must + // reach the traversal as data. The subscription answers a tuple for exactly + // this: resolving a wrapper promise with the tree directly would assimilate + // it — `resolve` treats a `then` as a resolver and waits for a settlement + // that never comes. + // + // The `then` arrives after the promise has fulfilled, because + // `Promise.resolve({ then })` never settles in the first place, in any + // implementation and with no runner involved. + fulfilledProofTreeWithAThenTestIsWalked: async () => { + const report = await run({ + nested: () => { + /** @type {{ sibling: () => void, then?: () => void }} */ + const tree = { sibling: () => undefined } + const promised = Promise.resolve(tree) + tree.then = () => undefined + return promised + }, + }) + assertEq(report.totals.tests, 3) + assertEq(report.totals.failed, 0) + }, // The case plain `await` cannot handle, reached most naturally by // `class Sub extends Promise { then() {} }`: `await` adopts a promise's // internal state only when its `constructor` is the intrinsic `Promise`, diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index 7100e54be..c4a314357 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -93,10 +93,14 @@ catch. Two things follow that are easy to get wrong: -- **The runner needs no promise handling of its own.** FunctionalScript cannot - produce a promise, so nothing the browser executes can be one. `fjs t`'s - `instanceof Promise` is kept only so both runners' `sandbox` reads the same, - and the cross-realm machinery that used to sit here is gone. See +- **The runner's promise handling is a required guard, not decoration.** + FunctionalScript as specified has no promises, so a conforming proof produces + none — but selection is by filename with no content check, so a module that + does not conform is loaded and can return one. The `instanceof Promise` check + and the settlement behind it are what keep that from silently losing a + sub-tree, and they are not to be deleted on the grounds that the language + forbids the input. What *was* deleted is the `Symbol.species` recovery + machinery, which is a different thing. See [imports, promises and realms](imports-promises-realms.md). - **The impure proofs that drive the browser runner are not part of the suite.** `emergent_testing/browser/proof.mjs` tests browser code, but it is `.mjs`, so @@ -118,9 +122,12 @@ The named `proof` export is the source of truth; filenames are conventions. omission or a Node fallback — when the graph reaches a `node:` import or an unresolved external package. -Extending selection to generic `.mjs` modules with Node-dependent graphs is -optional, later, and may introduce environment metadata; it does not block the -first working browser suite. +Extending selection to generic `.mjs` modules is **not** planned — see the scope +section above, which supersedes this paragraph's earlier "optional, later" +framing. A Node-dependent proof needs `node:fs`, `node:vm`, `process` and a +filesystem, none of which a page has, and no environment metadata changes that. +What remains open is the *rejection*: a `.f.mjs` whose graph reaches a `node:` +import must be reported, which is the dependency-graph acceptance above. ### In-browser runner and report From 2a0676a1edaca5ea870d05b98b182444208e77ee Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 15:46:05 -0700 Subject: [PATCH 110/370] docs: make inference the source of type facts --- todo/rtti-type-system.md | 48 +++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/todo/rtti-type-system.md b/todo/rtti-type-system.md index 1398c8887..bd579ee28 100644 --- a/todo/rtti-type-system.md +++ b/todo/rtti-type-system.md @@ -121,6 +121,10 @@ synthetic variable. Stage 8 records the choice that follows. /*: myType */ ``` +Annotations are **secondary to inference**: they may guide or constrain the +checker, but they are not the source of truth and cannot make an unproven type +claim true. + The body is a **name** — a single identifier, bound in the module by a `const` or an `import`, whose value is an RTTI schema. Nothing else is accepted: no call, no member access, no operator, no literal. The two forms hold the same @@ -451,29 +455,33 @@ which change what a generated declaration means, and rendering a schema is not the same as knowing which export carries it. See stage 1. It is also what lets a `.f.mjs` module drop its JSDoc without any consumer noticing. -### Proof-backed narrowing and specialization +### Inference, narrowing, and specialization This system should have no unchecked equivalent of TypeScript's `as`, `as any`, double casts, or another construct that simply tells the checker to trust a type -claim. A type or refinement claim must instead be established in one of two -ways: - -1. **Static proof.** The type-system compiler proves the property from the - program and the RTTI facts it already knows. -2. **Runtime proof.** An assertion checks the property and narrows the value on - the successful branch. If it fails, evaluation cannot continue on that - branch. - -The assertion is an ordinary FunctionalScript computation, not type syntax. The -important invariant is that every narrowing is backed by either a compile-time -proof or an executed runtime check; uncertainty becomes a proof obligation, not -an escape hatch. - -That does not imply permanent runtime overhead. Assertions are ordinary EDAG -computations. If the compiler proves an assertion is total and always succeeds, -it may remove it; proving only that the failure branch is unreachable is not -enough. A conservative compiler can keep more checks while a stronger one -removes them without weakening type safety. +claim. + +**Types are inferred from program behavior.** On a successful EDAG path, each +operation may refine what is known about its inputs and outputs; branches that +do not continue are excluded from that continuation. `assert` is only a +convenient ordinary operation for making such a refinement explicit, not +privileged type-system machinery. A validator's successful branch narrows by +the same rule. + +Because throws are generally semantically equivalent in EDAG, refinement +normally depends on the facts established by reaching a path, not on which +throw would have happened first. Exceptional cases can be decided later without +changing this direction. + +There is no unchecked escape hatch: narrowing follows from inference over +program semantics. Annotations may guide or constrain inference, but they do +not make an unproven claim true. + +This does not imply permanent runtime overhead. A check used only to establish +a fact is ordinary EDAG computation. If the compiler proves that removing it +preserves semantics — for a throwing check, that includes proving it total and +always successful — it may remove the check. A conservative compiler can keep +more checks while a stronger one removes them without weakening type safety. **The same proofs are optimization facts.** They can justify specialization where the compiler proves the specialized operation equivalent to the original. From 48ba2173192886d53bf3353d751cf29d2f321e1a Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Thu, 27 Aug 2026 15:58:11 -0700 Subject: [PATCH 111/370] docs: reconcile annotation design role --- spec/todo/3360-type-annotations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/todo/3360-type-annotations.md b/spec/todo/3360-type-annotations.md index f1cc9bcd2..2bb89328d 100644 --- a/spec/todo/3360-type-annotations.md +++ b/spec/todo/3360-type-annotations.md @@ -241,8 +241,8 @@ annotation form and how a name resolves — rather than a paraphrase of a stage. ## Related - [rtti-type-system](../../todo/rtti-type-system.md) — the epic this - document is the spec-side half of: RTTI as the sole source of truth for - compile-time and run-time verification. Stages 2–5 land here. + document supports: RTTI as the sole source of truth for compile-time and + run-time verification. Stages 2–5 land here. - [`fjs/rtti/README.md`](../../fjs/rtti/README.md) — the schema system this builds on. - [`fjs/rtti/todo/668-rtti-function-types.md`](../../fjs/rtti/todo/668-rtti-function-types.md) — From 8721753626cb6d308ca641c1846f21d30be6e822 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 23:05:46 +0000 Subject: [PATCH 112/370] emergent_testing: keep `instanceof` + `await`, record what that gives up Settles the promise handling at the simplest thing that matches `fjs t`, and moves the rest to the todo to implement when an input needs it. I had argued the deleted machinery must come back because removing it regresses behaviour. Measured against main it does -- on seven inputs. None of them is reachable from authored FunctionalScript, which has no `Promise`, `class`, `Proxy` or `Symbol`, and the browser suite runs authored FunctionalScript. `fjs t` has had every one of these gaps for the project's whole life with no incident, which is the evidence I should have weighed first. REVIEW.md's test is who runs the code and whether the input is real; I read "never deferrable" and skipped the qualifier. So the subscription and its two proofs go back out, leaving `instanceof Promise` and `await` -- the whole of `fjs t`'s promise handling, spelled the same way. `todo/imports-promises-realms.md` gains the measurements: a table of seven inputs with what the machinery did and what a runner does without it, what each deleted piece was for -- which was written down nowhere, and is why deleting it looked free -- and three things to consider before restoring any of it: check whether the scope rule actually moved, put it in the shared `sandbox` rather than one host, and note that two of the rows are hangs, whose general answer is a deadline rather than a promise-shaped defence. `fjs t` 3478/3478, `npx tsc` clean. --- changelog/unreleased/1742.md | 8 +-- fjs/emergent_testing/browser.mjs | 68 ++++--------------- fjs/emergent_testing/browser/proof.mjs | 41 +++-------- .../todo/imports-promises-realms.md | 46 +++++++++++++ 4 files changed, 74 insertions(+), 89 deletions(-) diff --git a/changelog/unreleased/1742.md b/changelog/unreleased/1742.md index 3380ea114..b597a6533 100644 --- a/changelog/unreleased/1742.md +++ b/changelog/unreleased/1742.md @@ -1,7 +1,7 @@ - **BREAKING CHANGES:** `emergent_testing`: the browser runner awaits only `instanceof Promise` values, as `fjs t` does. A promise from another realm is walked as a proof tree rather than awaited, the gap `fjs t` has had all along. - A same-realm promise is settled through the intrinsic `Promise.prototype.then`, - so the value's own `then` and a custom `Symbol.species` cannot divert it, and - a promise whose `constructor` has been replaced still settles. A species that - throws, or that is not a constructor, is reported as that test's failure + A same-realm promise is awaited. The `Symbol.species` machinery that defended + against hostile and cross-realm promises is gone; authored FunctionalScript + cannot construct those inputs, and `todo/imports-promises-realms.md` records + each case with what a runner does without it diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index e0ef04f31..192d3318f 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -74,45 +74,6 @@ const errorDetails = error => { /** @typedef {{ readonly status: string, readonly browser: string, readonly totals: { readonly tests: number, readonly passed: number, readonly failed: number }, readonly duration: number, readonly results: readonly _BrowserTestResult[] }} BrowserTestReport */ -/** - * Waits for a promise the way `await` does when it can — by the promise's own - * settlement, not by anything the value can replace. - * - * `await` is not always that. It adopts a promise's internal state only when the - * value's `constructor` is the intrinsic `Promise`; otherwise resolution - * assimilates it by calling its `then`, so a `Promise` subclass — or a promise - * whose `constructor` has been replaced — that also overrides `then` never - * settles, and the run hangs with no report. The intrinsic `then`, applied - * directly, ignores the override. - * - * **The `Reflect.apply` is outside the executor on purpose.** A throw inside a - * `new Promise` executor rejects that promise instead of propagating, and this - * one has to propagate: a promise whose `constructor[Symbol.species]` fails - * throws here, and the caller reports it against the test that produced the - * value — which is what `fjs t` does with it too. - * - * **The answer is a tuple, for the same reason `fn()`'s is.** Resolving the - * wrapper with the fulfilled value directly would assimilate it: a proof tree - * carrying a zero-argument `then` test is a thenable to `resolve`, which would - * call it as a resolver and wait for a settlement that never comes. The tree - * has to reach the traversal as data, so it travels wrapped. - * - * @type {(value: Promise) => Promise} - */ -const intrinsicSubscribe = value => { - /** @type {(v: readonly [unknown]) => void} */ - let ok = () => undefined - /** @type {(e: unknown) => void} */ - let no = () => undefined - /** @type {Promise} */ - const settled = new Promise((resolve, reject) => { ok = resolve; no = reject }) - Reflect.apply(Promise.prototype.then, value, [ - /** @type {(v: unknown) => void} */ (v => ok([v])), - no, - ]) - return settled -} - /** * A failure of a whole module — one that will not link, or whose `proof` export * cannot be enumerated. It does not go through `testResult`, and that is the @@ -185,17 +146,18 @@ const runOne = (module, path, throws, fn, result) => { result(failure) return [failure] } - // `instanceof Promise` decides, exactly as `fjs t`'s `sandbox` decides. - // Settlement then goes through the intrinsic `then` rather than `await`, - // for the reason `intrinsicSubscribe` gives: `await` adopts internal state - // only for a promise whose `constructor` is the intrinsic `Promise`, and - // assimilates the rest through a `then` the value can replace. + // `instanceof Promise`, then `await` — the whole of `fjs t`'s promise + // handling, spelled the same way here. // - // Neither the value's own `then` nor its `constructor[Symbol.species]` can - // divert the result. That is not immunity: a species that throws, or that - // is not a constructor, fails while the intrinsic `then` reads it, and the - // failure is reported against the test that produced the value — the same - // outcome `fjs t` gives it, and better than losing the sub-tree in silence. + // It is deliberately not more than that. A promise can replace its own + // `then`, present a `constructor` that is not the intrinsic `Promise`, or + // carry a `Symbol.species` that fails, and each of those defeats `await` in + // a different way. Defending against them takes about 150 lines, none of + // which authored FunctionalScript can reach: it has no `Promise`, no + // `class`, no `Proxy` and no `Symbol`. `todo/imports-promises-realms.md` + // records each case, what the deleted machinery did about it, and what a + // runner does without it — to be implemented when an input that needs it + // actually exists. // // The value is wrapped in a tuple first so that resolving it cannot // assimilate a proof tree carrying a `then` key: such a tree is a sub-tree @@ -225,11 +187,11 @@ const runOne = (module, path, throws, fn, result) => { if (!isPromise) { return passed(value) } /** @type {readonly [unknown]} */ let resolved - // Only the subscription is guarded. A throw from `passed` is the - // traversal's own and has its own handling; catching it here would - // report a broken proof tree as a rejected promise. + // Only the `await` is guarded. A throw from `passed` is the traversal's + // own and has its own handling; catching it here would report a broken + // proof tree as a rejected promise. try { - resolved = await intrinsicSubscribe(/** @type {Promise} */ (value)) + resolved = [await value] } catch (error) { return failed(error) } diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index e529fa769..958cdedcc 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -229,13 +229,12 @@ export const proof = { assertEq(report.results[0]?.path, '.nested') assertEq(report.results[0]?.message, 'trap') }, - // A promise can replace its own `then`, and it can make - // `constructor[Symbol.species]` build something that is not a promise at - // all. `value.then(...)` consults both. The runner subscribes with the - // *intrinsic* `then`, which consults neither — and which, unlike `await`, - // works even when the value's `constructor` is not the intrinsic `Promise`. - // These three pin the settlement path. - subscriptionIgnoresAnOwnThenOverride: async () => { + // `await`, not `value.then(...)`: `.then` calls the value's own `then` and + // builds its answer through `constructor[Symbol.species]`, so a promise + // carrying either can hand back something that is not its result. `await` + // adopts a same-realm promise's internal state instead. These pin that the + // runner settles the way `fjs t` settles. + awaitIgnoresAnOwnThenOverride: async () => { const promised = Promise.resolve({ child: () => undefined }) // A no-op override: anything that calls it instead of awaiting gets // `undefined` and loses the subtree. @@ -244,7 +243,7 @@ export const proof = { assertEq(report.totals.tests, 2) assertEq(report.results[1]?.path, '.nested().child') }, - subscriptionIgnoresACustomSpecies: async () => { + awaitIgnoresACustomSpecies: async () => { // `then` builds its answer through `constructor[Symbol.species]`, and // this one returns an ordinary object, so `.then` would hand back a // non-promise before the proof had settled. @@ -270,19 +269,7 @@ export const proof = { // The `then` arrives after the promise has fulfilled, because // `Promise.resolve({ then })` never settles in the first place, in any // implementation and with no runner involved. - fulfilledProofTreeWithAThenTestIsWalked: async () => { - const report = await run({ - nested: () => { - /** @type {{ sibling: () => void, then?: () => void }} */ - const tree = { sibling: () => undefined } - const promised = Promise.resolve(tree) - tree.then = () => undefined - return promised - }, - }) - assertEq(report.totals.tests, 3) - assertEq(report.totals.failed, 0) - }, + // The case plain `await` cannot handle, reached most naturally by // `class Sub extends Promise { then() {} }`: `await` adopts a promise's // internal state only when its `constructor` is the intrinsic `Promise`, @@ -291,17 +278,7 @@ export const proof = { // // This is a regression guard. The runner handled it before this change; an // intermediate version of this change, which used plain `await`, did not. - promiseWithReplacedConstructorStillSettles: async () => { - // Built by hand rather than with `class extends`: the point is a value - // whose `constructor` is not the intrinsic `Promise` and whose `then` is - // overridden, which is what makes `await` assimilate instead of adopt. - const promised = Promise.resolve({ child: () => undefined }) - Object.defineProperty(promised, 'constructor', { value: function Sub() {} }) - Object.defineProperty(promised, 'then', { value: () => undefined }) - const report = await run({ nested: () => promised }) - assertEq(report.totals.tests, 2) - assertEq(report.results[1]?.path, '.nested().child') - }, + // The other half of the species story, and the half the deleted // `species.proof.mjs` used to cover: `await` is not *immune* to a custom // species, only undiverted by a valid one. A species that throws fails while diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 02d296147..df775c0f1 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -339,6 +339,52 @@ returning a promise that never settles hangs any runner — `() => new Promise(() => {})` needs no subclass and no override. Bounding a proof's running time is the general answer and is not this issue. +### If a runner ever needs to accept generic input + +Everything below is deleted from the browser runner and unreachable from +authored FunctionalScript, which has no `Promise`, `class`, `Proxy` or `Symbol`. +It is recorded so that the day a runner must accept values it did not author — +impure `.mjs` proofs, an iframe, a worker, a third party calling +`runBrowserProofs` — the work is a lookup rather than a rediscovery. Each row is +measured, against the implementation that had the machinery and the one that +does not. + +| input | with the machinery | with `instanceof` + `await` | +| --- | --- | --- | +| cross-realm promise resolving to a sub-tree | subtree runs | subtree never discovered, reported `passed` | +| cross-realm promise that rejects | reported as a failure | **the process dies** on an unhandled rejection, before any report | +| object with `Promise.prototype` on its chain and no internal slots | walked as a proof tree | reported as a failure; its sub-tree lost | +| promise whose `constructor[Symbol.species]` throws, `constructor` configurable | recovered — subtree runs | reported as a failure | +| the same, `constructor` non-configurable | reported as a failure | reported as a failure | +| non-extensible impostor | walked as a proof tree | reported as a failure | +| promise fulfilled with a proof tree that gains a `then` test afterwards | walked | walked | + +And what each deleted piece was for, which was written down nowhere and is why +deleting it looked free: + +- **the intrinsic `Promise.prototype.then` as the brand check** — the only test + that survives a realm boundary, because `instanceof` asks about *this* realm's + prototype; +- **shadowing `constructor` with the intrinsic `Promise` and retrying** — one + step doing two jobs: it separates an impostor from a genuine promise whose + species failed, and it *recovers* the second; +- **`speciesFails` and the `Object.prototype.toString` re-check** — the same + separation for a value that cannot be shadowed. + +Three things to consider first, if the day comes: + +- **Ask what changed, before restoring any of it.** The reason this was deleted + is that the browser suite runs authored `.f.mjs` only. If that is still true, + the answer is still no machinery. If it is not, the scope rule in + [browser testing](browser-testing.md) is what actually moved, and this + follows from it rather than the other way round. +- **Put it in the shared `sandbox`, not one host.** `fjs t` has every gap in the + table above and has had them for the project's whole life, with no incident; + fixing one runner alone re-creates the split this work exists to remove. +- **Bound a proof's running time.** Two rows above are hangs, and the general + answer to a hang is a deadline, not a promise-shaped defence — `() => new + Promise(() => {})` needs none of this machinery to stop a run forever. + ### Constraints - An object carrying a `then` proof property must stay an ordinary proof tree. From 9f2b41d3fe3fa9ce9b78de3ea4ef66979bc43116 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 23:10:13 +0000 Subject: [PATCH 113/370] emergent_testing: state the scope in the README, where it will survive The rule was only ever written inside `todo/` files, which are deleted when their issue is done -- so the reason the runners are small would have gone with them. It is a standing property of the framework, so it belongs in `README.md`. Three sentences, unambiguous: in a browser this runs authored `.f.mjs` and nothing else; under `fjs t` it also runs a few impure `.mjs` proofs, because effect interpreters and the browser adapter can only be tested against a real host; and covering every edge case of plain JavaScript is explicitly not a goal. A proof is FunctionalScript -- no `Promise`, no `class`, no `Proxy`, no `Symbol` -- so values needing those to construct cannot come from one, and the runners do not defend against them. The two todos now read under that rule rather than restating it. --- fjs/emergent_testing/README.md | 27 +++++++++++++++++++ fjs/emergent_testing/todo/browser-testing.md | 3 +++ .../todo/imports-promises-realms.md | 5 ++++ 3 files changed, 35 insertions(+) diff --git a/fjs/emergent_testing/README.md b/fjs/emergent_testing/README.md index 883600e00..2cac814b8 100644 --- a/fjs/emergent_testing/README.md +++ b/fjs/emergent_testing/README.md @@ -5,6 +5,33 @@ verify behaviour. There is no `describe`/`it`/`expect` API — a proof is just a function or an object tree, so it can be imported, composed, and inspected like any other value. +## Scope + +**In a browser, this framework runs authored FunctionalScript — `.f.mjs` — and +nothing else.** `website/browser-prepare.mjs` selects on the extension and the +generated manifest contains only those modules. That is the design, not a first +iteration: an impure `.mjs` proof means whatever its host gives it — `node:fs`, +`node:vm`, `process`, `node:test`, a filesystem, a subprocess — and a page has +none of those. Loading Node-targeted JavaScript into a browser and expecting it +to test anything is not a goal. + +**Under `fjs t` the framework also runs a few impure `.mjs` proofs**, because +some things can only be tested against a real host — the effect interpreters, +and this framework's own browser adapter, which runs in Node against a DOM +stand-in. That is a deliberate, small exception, not an invitation. + +**Covering every edge case of plain JavaScript is explicitly not a goal.** A +proof is FunctionalScript: no `Promise`, no `class`, no `Proxy`, no `Symbol`, no +mutation. Values that need those to construct — a promise from another realm, an +object impersonating one, a hostile `Symbol.species` — cannot come from a proof, +and the runners do not defend against them. Where that costs something, it is +measured and written down in +[`todo/imports-promises-realms.md`](./todo/imports-promises-realms.md) rather +than guarded against in code, and the guard is written the day an input needs it. + +The two runners are meant to agree. Where they cannot, the difference is +recorded with the reason. + ## Concepts Three terms are used precisely throughout this document: diff --git a/fjs/emergent_testing/todo/browser-testing.md b/fjs/emergent_testing/todo/browser-testing.md index c4a314357..86ff78568 100644 --- a/fjs/emergent_testing/todo/browser-testing.md +++ b/fjs/emergent_testing/todo/browser-testing.md @@ -66,6 +66,9 @@ application root. The browser runner must not import the Node effect runner, ### Scope: authored FunctionalScript only +The standing rule lives in [the README](../README.md#scope); this section is +what it means for the browser suite specifically. + **The browser suite runs `.f.mjs` and nothing else.** `website/browser-prepare.mjs` selects on `name.endsWith('.f.mjs')`; the generated manifest currently carries 137 modules, none of them anything else. That is the design, not a first diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index df775c0f1..645d4d6ac 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -3,6 +3,11 @@ **Priority:** P3 **Status:** on-hold +> **Scope.** In a browser this framework runs `.f.mjs` and nothing else; under +> `fjs t` it also runs a few impure `.mjs` proofs; and covering every edge case +> of plain JavaScript is not a goal. See +> [the README](../README.md#scope) — everything below is read under that rule. + ### Problem Three mechanisms meet in the runner, none of them is written down as a rule, and From 896663815dad68b30843aa06014371b901b1d168 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 23:12:46 +0000 Subject: [PATCH 114/370] emergent_testing: drop comments left behind by deleted proofs Removing `fulfilledProofTreeWithAThenTestIsWalked` and `promiseWithReplacedConstructorStillSettles` took the bodies and left the comment blocks. One of them read "This is a regression guard" with no test under it, which is worse than nothing: it tells a reader the case is covered. Comments only; the suite is unchanged at 3478/3478. --- fjs/emergent_testing/browser/proof.mjs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 958cdedcc..887f9d150 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -260,25 +260,6 @@ export const proof = { assertEq(report.totals.failed, 1) assertEq(report.results[1]?.path, '.nested().child') }, - // A fulfilled value that is *itself* a proof tree with a `then` test must - // reach the traversal as data. The subscription answers a tuple for exactly - // this: resolving a wrapper promise with the tree directly would assimilate - // it — `resolve` treats a `then` as a resolver and waits for a settlement - // that never comes. - // - // The `then` arrives after the promise has fulfilled, because - // `Promise.resolve({ then })` never settles in the first place, in any - // implementation and with no runner involved. - - // The case plain `await` cannot handle, reached most naturally by - // `class Sub extends Promise { then() {} }`: `await` adopts a promise's - // internal state only when its `constructor` is the intrinsic `Promise`, - // and otherwise assimilates it through `then` — so a no-op override never - // settles and the run hangs with no report at all. - // - // This is a regression guard. The runner handled it before this change; an - // intermediate version of this change, which used plain `await`, did not. - // The other half of the species story, and the half the deleted // `species.proof.mjs` used to cover: `await` is not *immune* to a custom // species, only undiverted by a valid one. A species that throws fails while From bdc71abfb1fb86ce674c78ae222ebace8034158e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 23:16:46 +0000 Subject: [PATCH 115/370] changelog: no todo reference, and shorter `changelog/README.md` forbids naming a `todo/` file in an entry, because issue files are deleted when the work is done and the reference rots for a reader of the published package -- the same reasoning that moved the scope rule out of `todo/` and into `README.md` earlier in this branch. The entry named one anyway. Rewritten to observable behaviour only, and inside the ~4 line, ~250 character guidance rather than seven lines of rationale. The measurements stay where the README says they belong: the pull request and the module's own documents. --- changelog/unreleased/1742.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/changelog/unreleased/1742.md b/changelog/unreleased/1742.md index b597a6533..d6cc5973d 100644 --- a/changelog/unreleased/1742.md +++ b/changelog/unreleased/1742.md @@ -1,7 +1,4 @@ - **BREAKING CHANGES:** `emergent_testing`: the browser runner awaits only - `instanceof Promise` values, as `fjs t` does. A promise from another realm is - walked as a proof tree rather than awaited, the gap `fjs t` has had all along. - A same-realm promise is awaited. The `Symbol.species` machinery that defended - against hostile and cross-realm promises is gone; authored FunctionalScript - cannot construct those inputs, and `todo/imports-promises-realms.md` records - each case with what a runner does without it + `instanceof Promise` values, as `fjs t` does. A promise from another realm, or + a value impersonating one, is walked as a proof tree rather than awaited — the + `Symbol.species` machinery that handled those is gone From eb57170b879d8deb9a35ae18beff8f9d403c37f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 23:33:37 +0000 Subject: [PATCH 116/370] emergent_testing: the subset is a convention, and say so accurately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README paragraph I added stated the convention as an impossibility -- "a proof is FunctionalScript: no `Promise`, no `class`, no `Proxy`, no `Symbol` … values that need those cannot come from a proof" -- which is the eighth time in this branch I have written an unenforced convention as a fact, after correcting the same sentence in seven other places. Review supplied two counterexamples from this repository and both hold: `emergent_testing/proof.f.mjs` uses `Promise.resolve` and is in the browser manifest, and `Symbol.iterator` appears in ordinary `.f.mjs` such as `types/map/module.f.mjs`. So the invented list was wrong about the subset as well as about enforcement. Rewritten to what is true: FunctionalScript has no parser or compiler yet, so conformance is a convention held up by review and by the suffix -- the selector matches the suffix and a named `proof` export and nothing more -- and the convention has deliberate exceptions, including this framework's own proofs, which use `Promise` because they test how promises are handled. The scope is unchanged: the runners handle what proofs actually produce, the known gaps are measured and recorded, and a guard is written the day an input needs one. With a note that the day a parser exists, this stops being a convention and the question changes shape. --- fjs/emergent_testing/README.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/fjs/emergent_testing/README.md b/fjs/emergent_testing/README.md index 2cac814b8..d547d1d32 100644 --- a/fjs/emergent_testing/README.md +++ b/fjs/emergent_testing/README.md @@ -20,14 +20,23 @@ some things can only be tested against a real host — the effect interpreters, and this framework's own browser adapter, which runs in Node against a DOM stand-in. That is a deliberate, small exception, not an invitation. -**Covering every edge case of plain JavaScript is explicitly not a goal.** A -proof is FunctionalScript: no `Promise`, no `class`, no `Proxy`, no `Symbol`, no -mutation. Values that need those to construct — a promise from another realm, an -object impersonating one, a hostile `Symbol.species` — cannot come from a proof, -and the runners do not defend against them. Where that costs something, it is -measured and written down in -[`todo/imports-promises-realms.md`](./todo/imports-promises-realms.md) rather -than guarded against in code, and the guard is written the day an input needs it. +**Covering every edge case of plain JavaScript is explicitly not a goal.** +FunctionalScript has no parser or compiler yet, so "this file is +FunctionalScript" is a convention held up by review and by the `.f.mjs` suffix, +not a property anything checks — the browser's selector matches the suffix and a +named `proof` export, and nothing more. The convention also has deliberate +exceptions: this framework's own `proof.f.mjs` uses `Promise` precisely because +it tests how promises are handled, and `Symbol.iterator` is ordinary in `.f.mjs` +throughout the repository. + +So the runners handle what proofs actually produce, not everything a +`.f.mjs`-suffixed file could contain. A promise from another realm, a value +impersonating a promise, a hostile `Symbol.species`: none has ever come from a +proof, none is defended against, and each is measured and recorded in +[`todo/imports-promises-realms.md`](./todo/imports-promises-realms.md) with what +a runner does without the defence. The guard gets written the day an input needs +it — and the day a parser exists, this stops being a convention and the question +changes shape. The two runners are meant to agree. Where they cannot, the difference is recorded with the reason. From bc6b27f830b90534356315bf09b0d22cd64060a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 23:36:35 +0000 Subject: [PATCH 117/370] emergent_testing: a `.f.mjs` breaking the rule is a defect, not an exception My README paragraph had drifted twice: first it stated the convention as an impossibility, and then, correcting that, it legitimised the violations review had found -- "the convention also has deliberate exceptions". That is backwards. The rule stands and the code is wrong. Rewritten so the rule is stated plainly, with the enforcement gap named as a gap: FunctionalScript has no parser yet, so the rule is held by the suffix and by review, and a `.f.mjs` that breaks it gets a `todo/` and is removed rather than the rule being weakened. One of the two citations that prompted this was wrong and I accepted it uncritically. `Symbol.iterator` in `types/map/module.f.mjs` is not a violation -- `spec/todo/2330-property-accessor.md` says FunctionalScript allows the `Iterable` protocol explicitly. The other holds, and is narrower than reported: `emergent_testing/proof.f.mjs` constructs promises at lines 356 and 430, and it is the only `.f.mjs` that does. The remaining matches are the identifier `awaitIfPromise`, the effect constructor `awaitPromise`, and the word in a comment. Filed as `todo/promises-in-proof-f-mjs.md` with the constraint that matters: both fixtures pin real runner behaviour, so the replacement is what makes the change safe, not the deletion -- and `awaitIfPromise` with a virtual interpreter may express them with no `Promise` at all. --- fjs/emergent_testing/README.md | 28 +++++----- .../todo/imports-promises-realms.md | 2 + .../todo/promises-in-proof-f-mjs.md | 52 +++++++++++++++++++ 3 files changed, 67 insertions(+), 15 deletions(-) create mode 100644 fjs/emergent_testing/todo/promises-in-proof-f-mjs.md diff --git a/fjs/emergent_testing/README.md b/fjs/emergent_testing/README.md index d547d1d32..80de6597a 100644 --- a/fjs/emergent_testing/README.md +++ b/fjs/emergent_testing/README.md @@ -21,22 +21,20 @@ and this framework's own browser adapter, which runs in Node against a DOM stand-in. That is a deliberate, small exception, not an invitation. **Covering every edge case of plain JavaScript is explicitly not a goal.** -FunctionalScript has no parser or compiler yet, so "this file is -FunctionalScript" is a convention held up by review and by the `.f.mjs` suffix, -not a property anything checks — the browser's selector matches the suffix and a -named `proof` export, and nothing more. The convention also has deliberate -exceptions: this framework's own `proof.f.mjs` uses `Promise` precisely because -it tests how promises are handled, and `Symbol.iterator` is ordinary in `.f.mjs` -throughout the repository. - -So the runners handle what proofs actually produce, not everything a -`.f.mjs`-suffixed file could contain. A promise from another realm, a value -impersonating a promise, a hostile `Symbol.species`: none has ever come from a -proof, none is defended against, and each is measured and recorded in +FunctionalScript has no `Promise`, so a proof cannot return one, and the values +that need one to construct — a promise from another realm, a value impersonating +one, a hostile `Symbol.species` — cannot come from a proof either. The runners do +not defend against them. Each case is measured and recorded in [`todo/imports-promises-realms.md`](./todo/imports-promises-realms.md) with what -a runner does without the defence. The guard gets written the day an input needs -it — and the day a parser exists, this stops being a convention and the question -changes shape. +a runner does without the defence, and a guard is written the day an input needs +one. + +FunctionalScript has no parser or compiler yet, so that rule is held up by the +`.f.mjs` suffix and by review rather than by a check — the browser's selector +matches the suffix and a named `proof` export, nothing more. **A `.f.mjs` that +breaks the rule is a defect to fix, not an exception to design around.** Where +one is found it gets a `todo/` and is removed; the rule is not weakened to +accommodate it. The two runners are meant to agree. Where they cannot, the difference is recorded with the reason. diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 645d4d6ac..40e717757 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -7,6 +7,8 @@ > `fjs t` it also runs a few impure `.mjs` proofs; and covering every edge case > of plain JavaScript is not a goal. See > [the README](../README.md#scope) — everything below is read under that rule. +> A `.f.mjs` that breaks it is a defect, not an exception: see +> [promises in `proof.f.mjs`](promises-in-proof-f-mjs.md). ### Problem diff --git a/fjs/emergent_testing/todo/promises-in-proof-f-mjs.md b/fjs/emergent_testing/todo/promises-in-proof-f-mjs.md new file mode 100644 index 000000000..a12b8caed --- /dev/null +++ b/fjs/emergent_testing/todo/promises-in-proof-f-mjs.md @@ -0,0 +1,52 @@ +## Remove the `Promise` construction from `proof.f.mjs` + +**Priority:** P3 +**Status:** open + +### Problem + +FunctionalScript has no `Promise`, and [`../proof.f.mjs`](../proof.f.mjs) +constructs two: + +- line 356, `registerNoopCtx` — a stand-in `TestContext` whose `test` answers + `Promise.resolve()`; +- line 430 — a fixture proof `{ a: () => Promise.resolve(undefined) }`, checking + that a leaf returning a promise is awaited. + +Both are fixtures for behaviour the *runner* has, not FunctionalScript the +language, and the file they live in is authored `.f.mjs` — which the browser +suite selects, since selection is by suffix and a named `proof` export. So the +repository's own proof file breaks the rule the runners rely on, and is loaded +into a browser while doing it. + +This is the only such file. The other `.f.mjs` matches for "Promise" are the +identifier `awaitIfPromise`, the effect constructor `awaitPromise`, and the word +inside a JSDoc comment — none of them the global. + +### Preliminary design + +Both fixtures exist to test *impure* behaviour, so the straightforward move is +into an impure `.mjs` proof beside the existing ones. Two things to decide while +doing it: + +- **What covers the awaited-leaf path afterwards.** Deleting the fixture without + replacing it drops coverage of a real runner behaviour, which is worse than + the violation. The replacement is what makes this change safe, not the + deletion. +- **Whether the effect layer already offers a promise-free way to express it.** + `awaitIfPromise` takes a value and answers an effect; a virtual interpreter + can supply the settled result without a `Promise` existing anywhere in the + proof. If that works, the fixture stays pure and nothing moves. + +### Constraints + +- Do not weaken the rule to accommodate the file. A `.f.mjs` that constructs a + `Promise` is a defect; this issue removes it. +- Coverage does not drop: the runner behaviour these fixtures pin has to stay + pinned. + +### Related + +- [Imports, promises and realms](imports-promises-realms.md) — what the runners + do and do not handle, and why the rule matters. +- [The framework's scope](../README.md#scope). From 7d834e35f2bf3f7826e6a13bf65fa83c55bcfad8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 23:37:46 +0000 Subject: [PATCH 118/370] todo: `Symbol` is available, in one context -- say which I had written that authored FunctionalScript "has no `Promise`, `class`, `Proxy` or `Symbol`". The `Symbol` half is wrong: `Symbol.iterator` is permitted inside the `Iterable` protocol, per `spec/todo/2330-property-accessor.md`. What is not permitted is `Symbol` generally, which is the distinction the sentence flattened. Restated precisely, with the citation, and noting the part that actually matters here: the `Iterable` protocol builds none of the values in the table above. That keeps the argument -- these inputs are unreachable from a proof -- while dropping an invented subset description. This is the last of those lists in my documents; the README states only the fact it needs, that FunctionalScript has no `Promise`. --- fjs/emergent_testing/todo/imports-promises-realms.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 40e717757..7f8747d5d 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -349,7 +349,10 @@ proof's running time is the general answer and is not this issue. ### If a runner ever needs to accept generic input Everything below is deleted from the browser runner and unreachable from -authored FunctionalScript, which has no `Promise`, `class`, `Proxy` or `Symbol`. +authored FunctionalScript, which has no `Promise`. `Symbol` is available only as +`Symbol.iterator`, inside the `Iterable` protocol +([`spec/todo/2330-property-accessor.md`](../../../spec/todo/2330-property-accessor.md)), +which builds none of these values. It is recorded so that the day a runner must accept values it did not author — impure `.mjs` proofs, an iframe, a worker, a third party calling `runBrowserProofs` — the work is a lookup rather than a rediscovery. Each row is From 749479f61ab647a680f0d065c76503ebdb673477 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 23:38:34 +0000 Subject: [PATCH 119/370] todo: stop paraphrasing the subset; state only what this file needs Third correction to the same sentence. "No `Symbol`" was wrong; "`Symbol` available only as `Symbol.iterator` inside the `Iterable` protocol" was still wrong, because it is *iterable objects* that FunctionalScript allows, not iterators -- an iterator is stateful, and `next()` mutates. The fix is not a fourth attempt at the wording. This file needs exactly one fact -- FunctionalScript has no `Promise` -- and the subset belongs to the spec. Characterising it from memory is what produced both wrong descriptions, and the same habit produced the "cannot produce a promise" sentence that had to be corrected in eight places. --- fjs/emergent_testing/todo/imports-promises-realms.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 7f8747d5d..e4bdccbf1 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -349,10 +349,9 @@ proof's running time is the general answer and is not this issue. ### If a runner ever needs to accept generic input Everything below is deleted from the browser runner and unreachable from -authored FunctionalScript, which has no `Promise`. `Symbol` is available only as -`Symbol.iterator`, inside the `Iterable` protocol -([`spec/todo/2330-property-accessor.md`](../../../spec/todo/2330-property-accessor.md)), -which builds none of these values. +authored FunctionalScript, which has no `Promise`. The subset itself is the +spec's to state, not this file's — and paraphrasing it from memory is what +produced two wrong descriptions here already. It is recorded so that the day a runner must accept values it did not author — impure `.mjs` proofs, an iframe, a worker, a third party calling `runBrowserProofs` — the work is a lookup rather than a rediscovery. Each row is From cfc831dbc69ebc31aeba8693202c22f8f80e9251 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 23:53:19 +0000 Subject: [PATCH 120/370] todo: the fulfilled-tree row is only true for an intrinsic constructor The table said "promise fulfilled with a proof tree that gains a `then` test afterwards | walked | walked". Measured, that holds only while the promise's `constructor` is the intrinsic `Promise`: intrinsic ctor + result gains then: 3t/0f replaced ctor + result gains then: HUNG `await` adopts a promise's internal state only for the intrinsic constructor; replace it and resolution goes through a wrapper whose resolver assimilates the tree's own `then`. Two rows added, and the mechanism stated once -- every hang in the table traces to that same sentence, which is more useful than three separate rows implying three separate problems. Reachability is unchanged: constructing any of them needs `Promise` and `Object.defineProperty`, so none can come from a proof. Recorded, not defended, per the scope rule. --- fjs/emergent_testing/todo/imports-promises-realms.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index e4bdccbf1..28220973a 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -367,6 +367,15 @@ does not. | the same, `constructor` non-configurable | reported as a failure | reported as a failure | | non-extensible impostor | walked as a proof tree | reported as a failure | | promise fulfilled with a proof tree that gains a `then` test afterwards | walked | walked | +| the same, with the promise's `constructor` replaced | walked | **hangs** — `await` wraps it, and the wrapper's resolver assimilates the tree's `then` | +| promise whose `constructor` is replaced and whose own `then` is overridden | walked | **hangs** | + +Two of those rows turn on the same mechanism and are worth stating once: `await` +adopts a promise's internal state only when its `constructor` is the intrinsic +`Promise`. Replace the constructor and resolution goes the long way round — +through a `then` the value may have overridden, and through a wrapper whose +resolver will assimilate a fulfilled proof tree that carries a `then` of its +own. Every hang in this table traces back to that one sentence. And what each deleted piece was for, which was written down nowhere and is why deleting it looked free: From e05530e4e03838d5de29f18a283a2024c637848f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 00:22:16 +0000 Subject: [PATCH 121/370] docs: split separate-private-types todo into two stages Stage 1 covers the source restructuring (typedef prohibition, types.ts closure, optional private.ts and meta/module.f.mjs, dependency order, breaking migrations). Stage 2 defers the packaging cleanup: deleting generated private.d.ts at prepack and semantic package validation. Stage 1 is shippable alone because types.ts must not depend on private.ts, so a shipped private.d.ts is declaration noise covered by the existing _ leak-tolerance policy, not a semantic dependency of the public surface. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/todo/separate-private-types.md | 82 +++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 8110d887f..644c7289c 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -13,7 +13,34 @@ The requirement is a clean, self-contained public declaration/API boundary. `private.ts` and subordinate modules such as `meta/module.f.mjs` are **tools** for reaching that result, not required companion files. -### Rules +### Staging + +The work lands in two stages that are shippable independently: + +1. **Stage 1 — source restructuring.** Everything below except + [Declaration emission and packaging](#declaration-emission-and-packaging): + the file-scope typedef prohibition, the public declaration closure in + `types.ts`, optional `private.ts` and `meta/module.f.mjs`, the dependency + order, breaking migrations, and the matching policy documentation. +2. **Stage 2 — packaging cleanup.** The + [Declaration emission and packaging](#declaration-emission-and-packaging) + rules: delete generated `private.d.ts` as the final `prepack` step and + validate the packed artifact semantically. + +Stage 1 is complete on its own. While Stage 2 has not landed, generated +`private.d.ts` files ship in the package. That is safe: `types.ts` must not +depend on `private.ts`, so no shipped public declaration semantically depends +on a `private.d.ts` — the shipped file is declaration noise only, the same +leak the existing `_` tolerance policy +([`../fsc/README.md`](../fsc/README.md)) already covers, consolidated into one +file per module. Deleting it in Stage 2 is therefore not a breaking change, +and the `_` leak-tolerance policy stays in force until Stage 2 removes the +last leak. + +A Stage 1 PR checks off the Stage 1 tasks and leaves this file in place; the +Stage 2 PR deletes it. + +### Rules (Stage 1) #### No file-scope typedefs in authored `.mjs` @@ -166,6 +193,8 @@ linkage exposes them. ### Declaration emission and packaging +This section is Stage 2. It may land after Stage 1 as a separate change. + If `private.ts` is used, keep it in the normal TypeScript program so source users are checked. Declaration emit may therefore create an intermediate `private.d.ts`. @@ -194,22 +223,29 @@ Package validation must check semantic dependencies, not raw text: ### Repository policy -When this TODO is implemented: +When Stage 1 is implemented: - update root `AGENTS.md` with the repository-wide rule that authored `.mjs` files may not contain file-scope JSDoc `@typedef`; - update `fjs/AGENTS.md` with the public-declaration-closure rule, optional `private.ts`, optional subordinate metaprogramming modules such as - `meta/module.f.mjs`, and the dependency-order guidance; + `meta/module.f.mjs`, and the dependency-order guidance. + +When Stage 2 is implemented: + - update `fjs/fsc/README.md` and delete or narrow `todo/blocked/jsdoc-typedef-strip-internal.md` so the repository does not keep - two conflicting private-type strategies. + two conflicting private-type strategies. Both documents describe the `_` + leak-tolerance policy, which remains factually correct until Stage 2 unships + the last private declaration. Authored TypeScript type modules (`types.ts`, and `private.ts` when present) remain type-only and use named `import type { ... }` imports. ### Tasks +#### Stage 1 — source restructuring + - [ ] Document the repository-wide prohibition on file-scope JSDoc `@typedef` in authored `.mjs`; allow function-local typedefs. - [ ] Migrate existing violations, including authored `.mjs` outside `fjs/` such @@ -230,23 +266,35 @@ type-only and use named `import type { ... }` imports. - [ ] Preserve leading `_` for private types and private runtime constants. - [ ] Treat chosen public import-path moves as breaking changes with no compatibility re-exports. +- [ ] Add fixtures/examples covering: public-declaration helpers, optional + `private.ts`, function-local proof typedefs, recursive RTTI kept in + `module.f.mjs`, optional `meta/module.f.mjs`, and authored `.mjs` outside + `fjs/`. +- [ ] Update root and `fjs/` `AGENTS.md` policy documentation. + +#### Stage 2 — packaging cleanup + - [ ] If `private.ts` is used, delete generated `private.d.ts` as the final `prepack` step. - [ ] Do not text-postprocess emitted declarations; validate semantic private dependencies and clean-consumer type checking instead. -- [ ] Add fixtures/examples covering: public-declaration helpers, optional - `private.ts`, function-local proof typedefs, recursive RTTI kept in - `module.f.mjs`, optional `meta/module.f.mjs`, retained non-semantic JSDoc - comments, and authored `.mjs` outside `fjs/`. -- [ ] Update root/fjs policy documentation and reconcile the old `_` leak policy. +- [ ] Add fixtures covering packaging: retained non-semantic JSDoc `@import` + comments in emitted declarations, absent private artifacts in the tarball, + and a clean package consumer. +- [ ] Update `fjs/fsc/README.md` and reconcile the old `_` leak policy and the + blocked `@internal` TODO. ### Acceptance criteria -- The public declaration/API surface is clean and self-contained. +#### Stage 1 — source restructuring + +- The public declaration surface is self-contained; no public declaration + semantically depends on `private.ts`. Generated `private.d.ts` files may + still ship until Stage 2 — the leak is consolidated, not yet removed. - No authored `.mjs` anywhere in the repository contains a file-scope JSDoc `@typedef`; function-local typedefs are allowed. -- `types.ts` contains the public declaration closure and does not depend on an - unshipped private type module. +- `types.ts` contains the public declaration closure and does not depend on + `private.ts`. - `private.ts`, when present, is an optional implementation tool rather than a required companion. - A subordinate module such as `meta/module.f.mjs`, when present, is an optional @@ -258,14 +306,20 @@ type-only and use named `import type { ... }` imports. `meta/module.f.mjs`; no metadata-specific coverage convention exists. - Chosen public import-path moves are breaking migrations with importers/changelog updated and no compatibility re-exports. +- Root `AGENTS.md` and `fjs/AGENTS.md` document the Stage 1 rules. + +#### Stage 2 — packaging cleanup + +- The public declaration/API surface is clean: no private type artifact that is + intended to be unshipped is present in the tarball. - If declaration emit creates `private.d.ts`, final-`prepack` cleanup removes it before packaging. - Emitted declarations are not text-postprocessed; retained JSDoc `@import` comments are allowed when they are non-semantic. - The packed artifact has no semantic dependency on an unshipped private type module, and a clean TypeScript consumer type-checks successfully. -- Root `AGENTS.md`, `fjs/AGENTS.md`, `fjs/fsc/README.md`, and the blocked - `@internal` TODO no longer prescribe conflicting rules. +- `fjs/fsc/README.md` and the blocked `@internal` TODO no longer prescribe + conflicting rules. ### Related From f6954c9bab696b405889447065fedfefb348cf86 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 00:40:26 +0000 Subject: [PATCH 122/370] emergent_testing: proof.f.mjs no longer constructs promises Implements and deletes `todo/promises-in-proof-f-mjs.md`: the two `Promise.resolve` fixtures were the last construction of the `Promise` global in any authored `.f.mjs`. Neither promise was ever consumed, which is what made this a simplification rather than a move to `.mjs`. Every mock runner in this file intercepts the `test` *effect* and reads the `TestContext` as data, so `registerNoopCtx.test` was never invoked; it now panics if called -- enforcing "never called" and satisfying `TestFn` honestly at once, since a throwing body has type `never`, assignable to the `Promise` the signature demands, with no cast. And `registerOne` routes every leaf through the `await` effect unconditionally while that proof's handler ignores the payload and answers `notImplemented`, so the awaited-leaf fixture reaches the same path with plain `undefined`. A grep for the `Promise` global across authored `.f.mjs` is now empty; the remaining matches are identifiers (`awaitIfPromise`, `awaitPromise`) and JSDoc types. `fjs t` 3478/3478, `npx tsc` clean. Local bun shows the same failure set with and without this diff (bun 1.3.11 here vs 1.4.0 in CI, which was green at the base commit), so none of it is this change's. Changelog: - `emergent_testing`: `proof.f.mjs` no longer constructs promises -- the last `Promise` construction in any authored `.f.mjs`. Both were unconsumed fixtures: the never-invoked `TestContext` stub now panics if called, and the awaited-leaf fixture reaches the `await` operation with a plain value --- changelog/unreleased/1743.md | 4 ++ fjs/emergent_testing/proof.f.mjs | 19 +++++-- .../todo/imports-promises-realms.md | 6 ++- .../todo/promises-in-proof-f-mjs.md | 52 ------------------- 4 files changed, 24 insertions(+), 57 deletions(-) create mode 100644 changelog/unreleased/1743.md delete mode 100644 fjs/emergent_testing/todo/promises-in-proof-f-mjs.md diff --git a/changelog/unreleased/1743.md b/changelog/unreleased/1743.md new file mode 100644 index 000000000..a36bf9922 --- /dev/null +++ b/changelog/unreleased/1743.md @@ -0,0 +1,4 @@ +- `emergent_testing`: `proof.f.mjs` no longer constructs promises — the last + `Promise` construction in any authored `.f.mjs`. Both were unconsumed + fixtures: the never-invoked `TestContext` stub now panics if called, and the + awaited-leaf fixture reaches the `await` operation with a plain value diff --git a/fjs/emergent_testing/proof.f.mjs b/fjs/emergent_testing/proof.f.mjs index 8712f282b..a6abb8539 100644 --- a/fjs/emergent_testing/proof.f.mjs +++ b/fjs/emergent_testing/proof.f.mjs @@ -352,8 +352,18 @@ export const reporterWriteFailure = () => { * ) => (s: _RegisterMockState) => readonly [_RegisterMockState, OpResult]} _RegisterTestOp */ -/** @type {TestContext} */ -const registerNoopCtx = { test: (_n, _o, _f) => Promise.resolve() } +/** + * A `TestContext` that is never invoked. Every mock runner below intercepts the + * `test` *effect* and reads the context as data, so `test` here exists only to + * satisfy the type — and it panics rather than answering, so an accidental call + * fails loudly instead of resolving quietly. A throw is also what lets a pure + * module satisfy `TestFn` at all: the body's type is `never`, which is + * assignable to the `Promise` the signature demands, with no `Promise` + * constructed and no cast. + * + * @type {TestContext} + */ +const registerNoopCtx = { test: (_n, _o, _f) => { throw 'registerNoopCtx is data, not a runner' } } /** * Builds a synchronous mock runner for `registerModule`'s `Test`/`All`/`Await` @@ -427,7 +437,10 @@ const registerBodyPanicsOnUndispatchableEffect = () => { // The runner has no `await`, which is what the body's channel carries. await: _p => s => [s, error(['notImplemented', 'await'])], })) - const proof = /** @type {const} */ ({ a: () => Promise.resolve(undefined) }) + // The leaf's value never needs to be a promise: `registerOne` routes every + // leaf through the `await` effect unconditionally, and this runner's + // handler ignores the payload and answers `notImplemented` regardless. + const proof = /** @type {const} */ ({ a: () => undefined }) runner([])(registerModule(registerNoopCtx, './a.f.ts', proof, '')) } diff --git a/fjs/emergent_testing/todo/imports-promises-realms.md b/fjs/emergent_testing/todo/imports-promises-realms.md index 28220973a..a06d781d5 100644 --- a/fjs/emergent_testing/todo/imports-promises-realms.md +++ b/fjs/emergent_testing/todo/imports-promises-realms.md @@ -7,8 +7,10 @@ > `fjs t` it also runs a few impure `.mjs` proofs; and covering every edge case > of plain JavaScript is not a goal. See > [the README](../README.md#scope) — everything below is read under that rule. -> A `.f.mjs` that breaks it is a defect, not an exception: see -> [promises in `proof.f.mjs`](promises-in-proof-f-mjs.md). +> A `.f.mjs` that breaks it is a defect, not an exception — the one known case, +> two `Promise.resolve` fixtures in `../proof.f.mjs`, is fixed: neither promise +> was ever consumed, since the mocks intercept the effects and read the context +> as data. ### Problem diff --git a/fjs/emergent_testing/todo/promises-in-proof-f-mjs.md b/fjs/emergent_testing/todo/promises-in-proof-f-mjs.md deleted file mode 100644 index a12b8caed..000000000 --- a/fjs/emergent_testing/todo/promises-in-proof-f-mjs.md +++ /dev/null @@ -1,52 +0,0 @@ -## Remove the `Promise` construction from `proof.f.mjs` - -**Priority:** P3 -**Status:** open - -### Problem - -FunctionalScript has no `Promise`, and [`../proof.f.mjs`](../proof.f.mjs) -constructs two: - -- line 356, `registerNoopCtx` — a stand-in `TestContext` whose `test` answers - `Promise.resolve()`; -- line 430 — a fixture proof `{ a: () => Promise.resolve(undefined) }`, checking - that a leaf returning a promise is awaited. - -Both are fixtures for behaviour the *runner* has, not FunctionalScript the -language, and the file they live in is authored `.f.mjs` — which the browser -suite selects, since selection is by suffix and a named `proof` export. So the -repository's own proof file breaks the rule the runners rely on, and is loaded -into a browser while doing it. - -This is the only such file. The other `.f.mjs` matches for "Promise" are the -identifier `awaitIfPromise`, the effect constructor `awaitPromise`, and the word -inside a JSDoc comment — none of them the global. - -### Preliminary design - -Both fixtures exist to test *impure* behaviour, so the straightforward move is -into an impure `.mjs` proof beside the existing ones. Two things to decide while -doing it: - -- **What covers the awaited-leaf path afterwards.** Deleting the fixture without - replacing it drops coverage of a real runner behaviour, which is worse than - the violation. The replacement is what makes this change safe, not the - deletion. -- **Whether the effect layer already offers a promise-free way to express it.** - `awaitIfPromise` takes a value and answers an effect; a virtual interpreter - can supply the settled result without a `Promise` existing anywhere in the - proof. If that works, the fixture stays pure and nothing moves. - -### Constraints - -- Do not weaken the rule to accommodate the file. A `.f.mjs` that constructs a - `Promise` is a defect; this issue removes it. -- Coverage does not drop: the runner behaviour these fixtures pin has to stay - pinned. - -### Related - -- [Imports, promises and realms](imports-promises-realms.md) — what the runners - do and do not handle, and why the rule matters. -- [The framework's scope](../README.md#scope). From a355054ae805cbf1bd5dc2875362c56c33841197 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 00:41:07 +0000 Subject: [PATCH 123/370] changelog: rename the entry to this PR's number --- changelog/unreleased/{1743.md => 1747.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/unreleased/{1743.md => 1747.md} (100%) diff --git a/changelog/unreleased/1743.md b/changelog/unreleased/1747.md similarity index 100% rename from changelog/unreleased/1743.md rename to changelog/unreleased/1747.md From 25a22f4f548188eabccf8bb81c1bc4330c4a4025 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 00:50:57 +0000 Subject: [PATCH 124/370] changelog: this PR is a test-only change and needs no entry AGENTS.md, verbatim: a PR that has no user-visible change -- "internal refactors, test-only changes, and PRs that only touch todo/" -- needs neither the entry file nor the `Changelog:` section. This one touches two proof fixtures and a todo; nothing in the published package behaves differently, so the entry was release-note noise. --- changelog/unreleased/1747.md | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 changelog/unreleased/1747.md diff --git a/changelog/unreleased/1747.md b/changelog/unreleased/1747.md deleted file mode 100644 index a36bf9922..000000000 --- a/changelog/unreleased/1747.md +++ /dev/null @@ -1,4 +0,0 @@ -- `emergent_testing`: `proof.f.mjs` no longer constructs promises — the last - `Promise` construction in any authored `.f.mjs`. Both were unconsumed - fixtures: the never-invoked `TestContext` stub now panics if called, and the - awaited-leaf fixture reaches the `await` operation with a plain value From e369f2c90b839e755aa27e58328cdf00b7a675fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 00:50:42 +0000 Subject: [PATCH 125/370] docs: reconcile typedef prescriptions in Stage 1 Stage 1 bans file-scope JSDoc typedefs, so the documents that prescribe creating them (fjs/fsc/README.md) or waiting for @internal/stripInternal (the blocked TODO) must be rewritten in Stage 1, not Stage 2. Only the leak-tolerance contract for emitted _ names and shipped private.d.ts survives until Stage 2 unships the last private declaration. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/todo/separate-private-types.md | 42 ++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 644c7289c..70e680c4a 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -33,9 +33,13 @@ depend on `private.ts`, so no shipped public declaration semantically depends on a `private.d.ts` — the shipped file is declaration noise only, the same leak the existing `_` tolerance policy ([`../fsc/README.md`](../fsc/README.md)) already covers, consolidated into one -file per module. Deleting it in Stage 2 is therefore not a breaking change, -and the `_` leak-tolerance policy stays in force until Stage 2 removes the -last leak. +file per module. Deleting it in Stage 2 is therefore not a breaking change. + +Only the leak-tolerance **contract** survives Stage 1: consumers must not +depend on emitted `_` names or on a shipped `private.d.ts`, so removing them +later is not breaking. The **prescription** to create file-scope `_` typedefs +and the wait-for-`@internal`/`stripInternal` strategy contradict Stage 1 and +are rewritten as part of it. A Stage 1 PR checks off the Stage 1 tasks and leaves this file in place; the Stage 2 PR deletes it. @@ -229,15 +233,18 @@ When Stage 1 is implemented: may not contain file-scope JSDoc `@typedef`; - update `fjs/AGENTS.md` with the public-declaration-closure rule, optional `private.ts`, optional subordinate metaprogramming modules such as - `meta/module.f.mjs`, and the dependency-order guidance. + `meta/module.f.mjs`, and the dependency-order guidance; +- rewrite the "Private JSDoc typedefs" section of `fjs/fsc/README.md`: authors + no longer create file-scope `_` typedefs; keep the leak-tolerance contract + for emitted `_` names and shipped `private.d.ts` until Stage 2; +- delete or narrow `todo/blocked/jsdoc-typedef-strip-internal.md`: this design + supersedes waiting for `@internal`/`stripInternal`, so the repository does not + keep two conflicting private-type strategies. When Stage 2 is implemented: -- update `fjs/fsc/README.md` and delete or narrow - `todo/blocked/jsdoc-typedef-strip-internal.md` so the repository does not keep - two conflicting private-type strategies. Both documents describe the `_` - leak-tolerance policy, which remains factually correct until Stage 2 unships - the last private declaration. +- remove the remaining leak-tolerance language for shipped private declarations + from `fjs/fsc/README.md`, since no private declaration ships any more. Authored TypeScript type modules (`types.ts`, and `private.ts` when present) remain type-only and use named `import type { ... }` imports. @@ -270,7 +277,9 @@ type-only and use named `import type { ... }` imports. `private.ts`, function-local proof typedefs, recursive RTTI kept in `module.f.mjs`, optional `meta/module.f.mjs`, and authored `.mjs` outside `fjs/`. -- [ ] Update root and `fjs/` `AGENTS.md` policy documentation. +- [ ] Update root and `fjs/` `AGENTS.md` policy documentation; rewrite the + `fjs/fsc/README.md` typedef prescription and delete or narrow the blocked + `@internal` TODO. #### Stage 2 — packaging cleanup @@ -281,8 +290,8 @@ type-only and use named `import type { ... }` imports. - [ ] Add fixtures covering packaging: retained non-semantic JSDoc `@import` comments in emitted declarations, absent private artifacts in the tarball, and a clean package consumer. -- [ ] Update `fjs/fsc/README.md` and reconcile the old `_` leak policy and the - blocked `@internal` TODO. +- [ ] Remove the remaining `_`/`private.d.ts` leak-tolerance language from + `fjs/fsc/README.md` once packaging is clean. ### Acceptance criteria @@ -307,6 +316,11 @@ type-only and use named `import type { ... }` imports. - Chosen public import-path moves are breaking migrations with importers/changelog updated and no compatibility re-exports. - Root `AGENTS.md` and `fjs/AGENTS.md` document the Stage 1 rules. +- No repository document prescribes creating file-scope JSDoc typedefs or + waiting for `@internal`/`stripInternal`: the `fjs/fsc/README.md` typedef + section is rewritten and the blocked `@internal` TODO is deleted or narrowed, + while the leak-tolerance contract for emitted `_` names and shipped + `private.d.ts` remains documented until Stage 2. #### Stage 2 — packaging cleanup @@ -318,8 +332,8 @@ type-only and use named `import type { ... }` imports. comments are allowed when they are non-semantic. - The packed artifact has no semantic dependency on an unshipped private type module, and a clean TypeScript consumer type-checks successfully. -- `fjs/fsc/README.md` and the blocked `@internal` TODO no longer prescribe - conflicting rules. +- `fjs/fsc/README.md` no longer documents leak tolerance for shipped private + declarations, since none ship. ### Related From bd40ed4b280d19b185438c4f2191dbc9884e8f44 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 00:59:58 +0000 Subject: [PATCH 126/370] docs: the underscore contract is permanent, not a Stage 2 leftover Stage 2 removes only generated private.d.ts. Underscore helpers retained in types.ts by the public declaration closure and underscore constants exported from meta/module.f.mjs keep shipping in emitted declarations, so the contract that emitted underscore names are not API survives Stage 2. Narrow the Stage 2 cleanup to the private.d.ts tolerance. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/todo/separate-private-types.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 70e680c4a..892920b23 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -41,6 +41,12 @@ later is not breaking. The **prescription** to create file-scope `_` typedefs and the wait-for-`@internal`/`stripInternal` strategy contradict Stage 1 and are rewritten as part of it. +The `_` half of that contract is permanent, not a Stage 2 leftover: `_` +helpers retained in `types.ts` by the public declaration closure and `_` +constants exported from `meta/module.f.mjs` for linkage keep shipping in +`types.d.ts` / `module.d.mts` after Stage 2. Stage 2 retires only the +`private.d.ts` tolerance. + A Stage 1 PR checks off the Stage 1 tasks and leaves this file in place; the Stage 2 PR deletes it. @@ -243,8 +249,11 @@ When Stage 1 is implemented: When Stage 2 is implemented: -- remove the remaining leak-tolerance language for shipped private declarations - from `fjs/fsc/README.md`, since no private declaration ships any more. +- narrow the `fjs/fsc/README.md` leak tolerance to what still ships by design: + drop the tolerance for shipped `private.d.ts`, which no longer exists, and + keep the permanent `_` contract — `_` names emitted into `types.d.ts` / + `module.d.mts` are not API, and renaming or removing one is not by itself a + breaking change. Authored TypeScript type modules (`types.ts`, and `private.ts` when present) remain type-only and use named `import type { ... }` imports. @@ -290,8 +299,9 @@ type-only and use named `import type { ... }` imports. - [ ] Add fixtures covering packaging: retained non-semantic JSDoc `@import` comments in emitted declarations, absent private artifacts in the tarball, and a clean package consumer. -- [ ] Remove the remaining `_`/`private.d.ts` leak-tolerance language from - `fjs/fsc/README.md` once packaging is clean. +- [ ] Narrow the `fjs/fsc/README.md` leak tolerance: drop the `private.d.ts` + tolerance, keep the permanent `_` contract for `_` declarations that + still ship (`types.ts` helpers, exported `meta/module.f.mjs` constants). ### Acceptance criteria @@ -319,8 +329,8 @@ type-only and use named `import type { ... }` imports. - No repository document prescribes creating file-scope JSDoc typedefs or waiting for `@internal`/`stripInternal`: the `fjs/fsc/README.md` typedef section is rewritten and the blocked `@internal` TODO is deleted or narrowed, - while the leak-tolerance contract for emitted `_` names and shipped - `private.d.ts` remains documented until Stage 2. + while the permanent `_` contract stays documented and the shipped + `private.d.ts` tolerance stays documented until Stage 2. #### Stage 2 — packaging cleanup @@ -332,8 +342,9 @@ type-only and use named `import type { ... }` imports. comments are allowed when they are non-semantic. - The packed artifact has no semantic dependency on an unshipped private type module, and a clean TypeScript consumer type-checks successfully. -- `fjs/fsc/README.md` no longer documents leak tolerance for shipped private - declarations, since none ship. +- `fjs/fsc/README.md` no longer needs tolerance for a shipped `private.d.ts`, + since none ships, and still documents the permanent `_` contract: `_` names + emitted into shipped declarations are not API. ### Related From dc7f7c848ee07f21358617feef421b40412e1564 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 01:04:25 +0000 Subject: [PATCH 127/370] docs: add the two remaining typedef prescriptions to the Stage 1 sweep todo/migrate-typescript-to-mjs.md prescribes file-scope _ typedefs and defers to stripInternal in its migration section and visibility task, and fjs/ci/todo/f-mjs-package-support.md repeats the prescription and requires such a typedef in a future fixture. Both must be reconciled in Stage 1 for its no-conflicting-prescriptions acceptance criterion to be meetable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/todo/separate-private-types.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 892920b23..21883b94e 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -245,7 +245,16 @@ When Stage 1 is implemented: for emitted `_` names and shipped `private.d.ts` until Stage 2; - delete or narrow `todo/blocked/jsdoc-typedef-strip-internal.md`: this design supersedes waiting for `@internal`/`stripInternal`, so the repository does not - keep two conflicting private-type strategies. + keep two conflicting private-type strategies; +- reconcile `todo/migrate-typescript-to-mjs.md`: its "Preserve private type + intent with `_`" section and its typedef-visibility migration task prescribe + file-scope `_` typedefs and defer to `stripInternal`; rewrite them to target + the Stage 1 destinations — `types.ts`, optional `private.ts`, function-local + typedefs; +- reconcile `fjs/ci/todo/f-mjs-package-support.md`: its declaration-emission + narrative repeats the same prescription, and its fixture task requires an + implementation-only file-scope `_` typedef in `.mjs`; retarget both to the + Stage 1 forms. When Stage 2 is implemented: @@ -287,8 +296,10 @@ type-only and use named `import type { ... }` imports. `module.f.mjs`, optional `meta/module.f.mjs`, and authored `.mjs` outside `fjs/`. - [ ] Update root and `fjs/` `AGENTS.md` policy documentation; rewrite the - `fjs/fsc/README.md` typedef prescription and delete or narrow the blocked - `@internal` TODO. + `fjs/fsc/README.md` typedef prescription; delete or narrow the blocked + `@internal` TODO; reconcile the typedef prescriptions in + `todo/migrate-typescript-to-mjs.md` and + `fjs/ci/todo/f-mjs-package-support.md`. #### Stage 2 — packaging cleanup @@ -328,9 +339,11 @@ type-only and use named `import type { ... }` imports. - Root `AGENTS.md` and `fjs/AGENTS.md` document the Stage 1 rules. - No repository document prescribes creating file-scope JSDoc typedefs or waiting for `@internal`/`stripInternal`: the `fjs/fsc/README.md` typedef - section is rewritten and the blocked `@internal` TODO is deleted or narrowed, - while the permanent `_` contract stays documented and the shipped - `private.d.ts` tolerance stays documented until Stage 2. + section, the `todo/migrate-typescript-to-mjs.md` migration prescriptions, and + the `fjs/ci/todo/f-mjs-package-support.md` narrative and fixture task are + rewritten, and the blocked `@internal` TODO is deleted or narrowed, while the + permanent `_` contract stays documented and the shipped `private.d.ts` + tolerance stays documented until Stage 2. #### Stage 2 — packaging cleanup From fdfeb14a49896a716cfc498a5df2218746fbe015 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 01:08:05 +0000 Subject: [PATCH 128/370] docs: define the Stage 1 documentation sweep by search, not by list Review keeps surfacing one more document that prescribes a file-scope typedef (latest: sync-interpreter-owner.md's proposed MemoryState); an enumerated list can never be shown complete. Make the sweep a repo-wide search for such prescriptions, keep the known instances as illustrative examples, and state the acceptance criterion as search-verified. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/todo/separate-private-types.md | 36 +++++++++++++++--------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 21883b94e..28fe762af 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -246,15 +246,18 @@ When Stage 1 is implemented: - delete or narrow `todo/blocked/jsdoc-typedef-strip-internal.md`: this design supersedes waiting for `@internal`/`stripInternal`, so the repository does not keep two conflicting private-type strategies; -- reconcile `todo/migrate-typescript-to-mjs.md`: its "Preserve private type - intent with `_`" section and its typedef-visibility migration task prescribe - file-scope `_` typedefs and defer to `stripInternal`; rewrite them to target - the Stage 1 destinations — `types.ts`, optional `private.ts`, function-local - typedefs; -- reconcile `fjs/ci/todo/f-mjs-package-support.md`: its declaration-emission - narrative repeats the same prescription, and its fixture task requires an - implementation-only file-scope `_` typedef in `.mjs`; retarget both to the - Stage 1 forms. +- sweep the remaining Markdown documents repo-wide — `todo/` issues, plans, + and READMEs — for text that prescribes adding a file-scope JSDoc `@typedef` + to an authored `.mjs` or defers private types to `@internal`/`stripInternal`, + and retarget each to the Stage 1 forms: `types.ts`, optional `private.ts`, + function-local typedefs. The sweep is defined by the search, not by a list; + instances known at the time of writing are + `todo/migrate-typescript-to-mjs.md` ("Preserve private type intent with `_`" + and the typedef-visibility migration task), + `fjs/ci/todo/f-mjs-package-support.md` (its declaration-emission narrative + and its `_`-typedef fixture task), and + `fjs/effects/memory/todo/sync-interpreter-owner.md` (its proposed + `MemoryState` file-scope typedef belongs in `types.ts`). When Stage 2 is implemented: @@ -297,9 +300,8 @@ type-only and use named `import type { ... }` imports. `fjs/`. - [ ] Update root and `fjs/` `AGENTS.md` policy documentation; rewrite the `fjs/fsc/README.md` typedef prescription; delete or narrow the blocked - `@internal` TODO; reconcile the typedef prescriptions in - `todo/migrate-typescript-to-mjs.md` and - `fjs/ci/todo/f-mjs-package-support.md`. + `@internal` TODO; sweep all remaining Markdown documents for file-scope + typedef prescriptions and retarget each to the Stage 1 forms. #### Stage 2 — packaging cleanup @@ -338,12 +340,10 @@ type-only and use named `import type { ... }` imports. updated and no compatibility re-exports. - Root `AGENTS.md` and `fjs/AGENTS.md` document the Stage 1 rules. - No repository document prescribes creating file-scope JSDoc typedefs or - waiting for `@internal`/`stripInternal`: the `fjs/fsc/README.md` typedef - section, the `todo/migrate-typescript-to-mjs.md` migration prescriptions, and - the `fjs/ci/todo/f-mjs-package-support.md` narrative and fixture task are - rewritten, and the blocked `@internal` TODO is deleted or narrowed, while the - permanent `_` contract stays documented and the shipped `private.d.ts` - tolerance stays documented until Stage 2. + waiting for `@internal`/`stripInternal` — verified by a repo-wide search, + not by checking an enumerated list. The permanent `_` contract stays + documented, and the shipped `private.d.ts` tolerance stays documented until + Stage 2. #### Stage 2 — packaging cleanup From 18a235581bae454c1d3dd41f25c44d42b20bb948 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:18:06 +0000 Subject: [PATCH 129/370] =?UTF-8?q?rtti:=20`option`=20is=20a=20nullary=20s?= =?UTF-8?q?chema=20denoting=20absence=20=E2=80=94=20stage=202=20of=20optio?= =?UTF-8?q?n-as-omission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Absence stops being a spelling of `undefined` and becomes a member of the set: a member that may be omitted is `or(option, t)`, `{}` and `{ a: undefined }` are distinct sets, and every reader, `subset`, both renderers and the JSON Schema derivation agree on which is which. - `option` is a new nullary tag; `visit` gains the case and both schema-form readers an `option` handler that rejects normally, so `orVisit` tries the other members of `or(option, t)` for a present value. - Absence is decided by the container loops before dispatch: a declared member with no own or inherited key succeeds iff its schema admits absence (`admitsAbsence`, traversing nested `or`s with a visited set); a prototype-supplied member is still held to the present part. - `parse` omits an absent member: the struct kind drops the key, the array kind rebuilds by slice-then-map, so a trailing absent run shortens the result and an interior hole survives — the old JSON round-trip defect of materialized `undefined` is gone. - Data form: `absentBit` as the fifth unit bit, excluded from `unknown`; `toData(option)` via an explicit `thunkUnion` case; inline rests are stripped of the bit (referenced ones exempt — `subset` resolves them rather than masking, which the absence-only cycle shows would be unsound); `trimPrefix` drops a trailing declared position restating its rest; the declared-member top is `or(option, unknown)`; `objectMayOmit`, `objectPresentSet` and a split `arraySetSubset` move to the bit; the data reader gets the same before-dispatch absence test. - Renderers: `Ts<>` renders an omittable member optional with absence stripped (`readonly a?: number`, `readonly [1, number?]` — exact under `exactOptionalPropertyTypes`), an interior tuple position as `T | undefined`, `array(option)` as `readonly []`; the runtime printer matches; `toJsonSchema` derives `required`/`minItems` from the bit while `stripUndefined` stays keyed on `undefined`. `Absent`, `_TsRaw` and `CheckRaw` are the raw-shape surface `Phantom` annotations need. - Every `option(t)` call site migrated to `or(option, t)` — deliberately narrowing: a present `undefined` is no longer accepted at those members — and the direct `or(…, undefined)` optionality spellings audited (`mcp/cas`, `media/json/schema`); `protocol/mcp`'s `_noParams` checks a read top-level value, so it carries `undefined` the value. - Docs and JSDoc swept; `parse-omits-undefined-members` dissolved and the `option-as-omission` todo completed, both deleted. Changelog: - **BREAKING CHANGES:** rtti's `option` is a nullary schema denoting absence; `option(t)` becomes `or(option, t)`, which also narrows — the faithful translation of the old set is `or(option, t, undefined)`. `parse` no longer materializes an absent member. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/option-as-omission.md | 20 + fjs/AGENTS.md | 5 +- fjs/cas/evo/module.f.mjs | 9 +- fjs/ci/common/module.f.mjs | 12 +- fjs/mcp/cas/module.f.mjs | 4 +- fjs/mcp/evo/module.f.mjs | 12 +- fjs/mcp/proof.f.mjs | 6 +- fjs/media/json/schema/module.f.mjs | 110 +-- fjs/media/json/schema/proof.f.mjs | 47 +- fjs/media/note/README.md | 4 +- fjs/media/note/module.f.mjs | 4 +- fjs/media/note/todo/extend-note-format.md | 4 +- fjs/media/revision/README.md | 10 +- fjs/media/revision/module.f.mjs | 6 +- fjs/media/revision/proof.f.mjs | 2 +- fjs/protocol/json_rpc/module.f.mjs | 6 +- fjs/protocol/mcp/README.md | 4 +- fjs/protocol/mcp/module.f.mjs | 25 +- fjs/rtti/README.md | 21 +- fjs/rtti/common/module.f.mjs | 46 ++ fjs/rtti/common/types.ts | 9 + fjs/rtti/data/README.md | 56 +- fjs/rtti/data/module.f.mjs | 252 +++++-- fjs/rtti/data/proof.f.mjs | 264 ++++++- fjs/rtti/data/types.ts | 31 +- fjs/rtti/host.proof.mjs | 38 +- fjs/rtti/module.f.mjs | 29 +- fjs/rtti/parse/module.f.mjs | 118 ++- fjs/rtti/parse/proof.f.mjs | 65 +- fjs/rtti/proof.f.mjs | 16 +- fjs/rtti/todo/checked-const-pin.md | 4 +- .../data-validate-admits-non-djs-values.md | 4 +- fjs/rtti/todo/option-as-omission.md | 713 ------------------ .../todo/parse-omits-undefined-members.md | 168 ----- fjs/rtti/ts/README.md | 4 +- fjs/rtti/ts/module.f.mjs | 87 ++- fjs/rtti/ts/proof.f.mjs | 109 ++- fjs/rtti/ts/types.ts | 417 +++++++--- fjs/rtti/types.ts | 17 +- fjs/rtti/validate/module.f.mjs | 36 +- fjs/rtti/validate/proof.f.mjs | 135 +++- fjs/types/phantom/types.ts | 15 + todo/rtti-type-system.md | 9 +- 43 files changed, 1578 insertions(+), 1375 deletions(-) create mode 100644 changelog/unreleased/option-as-omission.md delete mode 100644 fjs/rtti/todo/option-as-omission.md delete mode 100644 fjs/rtti/todo/parse-omits-undefined-members.md diff --git a/changelog/unreleased/option-as-omission.md b/changelog/unreleased/option-as-omission.md new file mode 100644 index 000000000..fc8b0dc8e --- /dev/null +++ b/changelog/unreleased/option-as-omission.md @@ -0,0 +1,20 @@ +- **BREAKING CHANGES:** rtti's `option` is a nullary schema denoting + **absence** — the member that is not there — so a member that may be + omitted is `or(option, t)` and absence stops being a spelling of + `undefined`: `{}` and `{ a: undefined }` are now distinct sets, told apart + by every reader and by `subset`. `option(t)` becomes `or(option, t)`, which + also **narrows**: a schema that accepted a present `undefined` at that + member no longer does — the faithful translation of the old set is + `or(option, t, undefined)`, and every migrated schema in this repository + took the narrowing deliberately. `parse` no longer materializes an absent + member: the struct kind drops the key, the array kind keeps a hole a hole + and shortens a trailing absent run, so an optional member survives a JSON + round-trip. In the data form absence is a fifth `unit` bit (`absentBit`), + excluded from `unknown`; a declared `unknown` member is therefore required + now, and "anything, or nothing" is `or(option, unknown)`. `Ts<>` and the + runtime printer render an omittable member optional with absence stripped + (`readonly a?: number`, `readonly [1, number?]`) — exact under + `exactOptionalPropertyTypes` — and `toJsonSchema` derives `required` and + `minItems` from the absent bit. A `Phantom` annotation on a schema whose + root admits absence must carry the new `Absent` marker, pinned with the + new `CheckRaw`. diff --git a/fjs/AGENTS.md b/fjs/AGENTS.md index eb11b882b..e979e8fdc 100644 --- a/fjs/AGENTS.md +++ b/fjs/AGENTS.md @@ -380,7 +380,7 @@ FunctionalScript data is immutable, but stock `tsc` widens literals by default 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 +conditionals, and references (`or(...)`, a bare `string` or `option`) 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. @@ -402,7 +402,8 @@ validate({ a: 42 }) // the same, with `` A cast there is the absence of a modifier on the callee, not a fact about the value — and it has to be repeated at every call, where the modifier is written -once. `rtti` (`or`, `option`, `array`, `record`), `rtti/validate`, +once. `rtti` (`or`, `array`, `record` — `option` is nullary and takes +nothing), `rtti/validate`, `rtti/parse`, `types/result` (`ok`, `error`), `protocol/mcp`'s `toolEntry`, and `bnf`'s `option` already carry it; a new schema- or literal-taking export should too. diff --git a/fjs/cas/evo/module.f.mjs b/fjs/cas/evo/module.f.mjs index 87514d2db..2a9dc76c8 100644 --- a/fjs/cas/evo/module.f.mjs +++ b/fjs/cas/evo/module.f.mjs @@ -458,6 +458,9 @@ const buildRevision = input => parents => { if (parentSubjectsResult[0] === 'error') { return parentSubjectsResult } const snapshotResult = resolveSnapshot(input)(subject)(parents) if (snapshotResult[0] === 'error') { return snapshotResult } + // `archived` and `lock` are omittable members of the revision schema, and + // an absent member is *absent* — spelling either as a present `undefined` + // would build a value the schema rejects. /** @type {Revision} */ const revision = { dialect, @@ -465,8 +468,8 @@ const buildRevision = input => parents => { parents: input.parents, snapshot: snapshotResult[1], generation: computeGeneration(parents), - archived: input.archived, - lock: input.lock, + ...(input.archived === undefined ? {} : { archived: input.archived }), + ...(input.lock === undefined ? {} : { lock: input.lock }), } const referencesResult = checkReferences(revision) if (referencesResult[0] === 'error') { return referencesResult } @@ -474,7 +477,7 @@ const buildRevision = input => parents => { ...revision, parents: revision.parents.map(canonicalHash), snapshot: canonicalHash(revision.snapshot), - lock: revision.lock === undefined ? undefined : canonicalLockField(revision.lock), + ...(revision.lock === undefined ? {} : { lock: canonicalLockField(revision.lock) }), }) } diff --git a/fjs/ci/common/module.f.mjs b/fjs/ci/common/module.f.mjs index 4bbf5901e..1c55977de 100644 --- a/fjs/ci/common/module.f.mjs +++ b/fjs/ci/common/module.f.mjs @@ -11,7 +11,7 @@ */ import { actions, images } from '../config/module.f.mjs' -import { option, array, record, string } from '../../rtti/module.f.mjs' +import { array, option, or, record, string } from '../../rtti/module.f.mjs' import { parse as rttiParse } from '../../rtti/parse/module.f.mjs' export const os = /** @type {const} */ (['ubuntu', 'macos', 'windows']) @@ -25,9 +25,9 @@ export const architecture = /** @type {const} */ (['intel', 'arm']) // `if`, `env` and much else — would need `open`. export const stepSchema = /** @type {const} */ ({ - run: option(string), - uses: option(string), - with: option(record(string)) + run: or(option, string), + uses: or(option, string), + with: or(option, record(string)) }) export const jobSchema = /** @type {const} */ ({ @@ -40,8 +40,8 @@ export const jobsSchema = record(jobSchema) export const gitHubActionSchema = /** @type {const} */ ({ name: string, on: { - pull_request: option({}), - merge_group: option({}) + pull_request: or(option, {}), + merge_group: or(option, {}) }, permissions: record(string), jobs: jobsSchema diff --git a/fjs/mcp/cas/module.f.mjs b/fjs/mcp/cas/module.f.mjs index e1a692774..288c7251a 100644 --- a/fjs/mcp/cas/module.f.mjs +++ b/fjs/mcp/cas/module.f.mjs @@ -143,13 +143,13 @@ import { assertNotNullish } from '../../asserts/module.f.mjs' /** Arguments for `cas_add`: content to store, with optional encoding type. */ export const casAddArgs = /** @type {const} */ ({ content: string, - type: or('text', 'base64', undefined) + type: or(option, 'text', 'base64') }) /** Arguments for `cas_get`: the cBase32 hash to look up; optionally request inline content. */ export const casGetArgs = /** @type {const} */ ({ hash: string, - content: option(boolean) + content: or(option, boolean) }) /** Arguments for `cas_list`: none. */ diff --git a/fjs/mcp/evo/module.f.mjs b/fjs/mcp/evo/module.f.mjs index 9b536a682..7e2280cad 100644 --- a/fjs/mcp/evo/module.f.mjs +++ b/fjs/mcp/evo/module.f.mjs @@ -49,7 +49,7 @@ * @import { Evo } from '../../cas/evo/types.ts' */ -import { string, option, array } from '../../rtti/module.f.mjs' +import { array, option, or, string } from '../../rtti/module.f.mjs' import { lockField } from '../../media/revision/module.f.mjs' import { evoSummary } from '../../cas/evo/module.f.mjs' import { toolEntry, toolResultStep } from '../../protocol/mcp/module.f.mjs' @@ -67,7 +67,7 @@ import { identity } from '../../types/function/module.f.mjs' * `Evo.list` — omitted lists the active subjects, `true` the archived ones. */ export const evoListArgs = /** @type {const} */ ({ - archived: option(true), + archived: or(option, true), }) /** Arguments for `evo_head`: the subject whose current heads are requested. */ @@ -96,10 +96,10 @@ export const evoRevisionArgs = /** @type {const} */ ({ */ export const evoAddArgs = /** @type {const} */ ({ parents: array(string), - snapshot: option(string), - subject: option(string), - archived: option(true), - lock: option(lockField), + snapshot: or(option, string), + subject: or(option, string), + archived: or(option, true), + lock: or(option, lockField), }) // ── Tool registry ──────────────────────────────────────────────────────────────── diff --git a/fjs/mcp/proof.f.mjs b/fjs/mcp/proof.f.mjs index ab2bd5da4..d0e7b3ced 100644 --- a/fjs/mcp/proof.f.mjs +++ b/fjs/mcp/proof.f.mjs @@ -16,7 +16,7 @@ import { assert, assertEq } from '../asserts/module.f.mjs' import { pureOk, step } from '../effects/module.f.mjs' import { create } from '../effects/memory/module.f.mjs' import { parse as parseJson } from '../media/json/module.f.mjs' -import { number as rttiNumber, option, string as rttiString } from '../rtti/module.f.mjs' +import { number as rttiNumber, option, or, string as rttiString } from '../rtti/module.f.mjs' import { parse as rttiParse } from '../rtti/parse/module.f.mjs' import { msb, u8ListToVec, vec8, repeat, length, maxLengthBytes } from '../types/bit_vec/module.f.mjs' import { vecToCBase32 } from '../basen/cbase32/module.f.mjs' @@ -46,8 +46,8 @@ const casGetResult = /** @type {const} */ ({ mimeType: rttiString, type: rttiString, uri: rttiString, - text: option(rttiString), - blob: option(rttiString), + text: or(option, rttiString), + blob: or(option, rttiString), }) const parseCasGetResult = rttiParse(casGetResult) diff --git a/fjs/media/json/schema/module.f.mjs b/fjs/media/json/schema/module.f.mjs index e7707b1ec..7a08c770c 100644 --- a/fjs/media/json/schema/module.f.mjs +++ b/fjs/media/json/schema/module.f.mjs @@ -27,7 +27,7 @@ import { assert, assertNotNullish } from '../../../asserts/module.f.mjs' import { at, definedEntries } from '../../../types/object/module.f.mjs' import { array, number, option, or, record, string } from '../../../rtti/module.f.mjs' -import { cmp, toData, unitBit, unknown as top, withoutUnits } from '../../../rtti/data/module.f.mjs' +import { absentBit, cmp, toData, unitBit, unknown as top, withoutUnits } from '../../../rtti/data/module.f.mjs' import { unknown as jsonUnknown } from '../rtti/module.f.mjs' /** @type {() => readonly ['const', typeof unknownConst]} */ @@ -49,33 +49,35 @@ export const unknown = unknownThunk /** A JSON Schema (draft 2020-12) document — the subset of keywords that `toJsonSchema` emits. */ /** @typedef {Ts} Unknown */ +// Every field may be **omitted** — a JSON Schema document carries only the +// keywords it needs, and JSON has no `undefined` to hold in a present field — +// so the two enumerated keywords spell their optionality as `or(option, …)` +// like the rest, not as a member `undefined`. const unknownConst = /** @type {const} */ ({ - $schema: option(string), - $ref: option(string), - $defs: option(record(unknown)), - type: or('boolean', 'number', 'string', 'integer', 'array', 'object', undefined), - const: option(jsonUnknown), - not: option(unknown), - anyOf: option(array(unknown)), - items: or(unknown, false, undefined), - prefixItems: option(array(unknown)), - minItems: option(number), - properties: option(record(unknown)), - required: option(array(string)), - additionalProperties: option(unknown), + $schema: or(option, string), + $ref: or(option, string), + $defs: or(option, record(unknown)), + type: or(option, 'boolean', 'number', 'string', 'integer', 'array', 'object'), + const: or(option, jsonUnknown), + not: or(option, unknown), + anyOf: or(option, array(unknown)), + items: or(option, unknown, false), + prefixItems: or(option, array(unknown)), + minItems: or(option, number), + properties: or(option, record(unknown)), + required: or(option, array(string)), + additionalProperties: or(option, unknown), }) /** * Hand-written base type used as the `$out` annotation on `unknown`. * - * The `?` markers are required even though `Ts<>` already includes `undefined` - * in each field type. Without `?`, `Unknown = _UnknownConst` would require all - * 12 fields to be present in every object literal returned by `toJsonSchema`, - * because TypeScript distinguishes "field absent" (`?`) from "field present but - * undefined" (`T | undefined`). JSON Schema objects only include the fields - * they need, so all fields must be optional. `$defs` is an *open* map — an - * absent entry types as `undefined`, so missing-reference handling cannot be - * skipped. + * The `?` markers spell what `or(option, …)` says in the schema: every field + * may be absent, and `Ts<>` renders such a member optional with absence + * stripped from its type, so each field here is `?:` over the member's + * present part. JSON Schema objects only include the keywords they need. + * `$defs` is an *open* map — an absent entry types as `undefined`, so + * missing-reference handling cannot be skipped. * @typedef {{ * readonly $schema?: Ts * readonly $ref?: Ts @@ -174,24 +176,25 @@ const unitSchemas = bits => [ /** * The length below which the array would leave a declared position that - * excludes `undefined` unfilled: one past the last such position, and zero - * when every position admits absence. The array counterpart of the `required` - * key list — an absent element reads as `undefined` just as an absent key - * does — and, arrays being contiguous, one number says it for every position. + * excludes **absence** unfilled: one past the last such position, and zero + * when every position admits absence. The array counterpart of the + * `required` key list — and, arrays being contiguous, one number says it for + * every position. * * @type {(rules: RuleSet) => (prefix: readonly Node[]) => number} */ const minLength = rules => prefix => - prefix.findLastIndex(n => !admitsUndefined(rules)(n)) + 1 + prefix.findLastIndex(n => !admitsAbsence(rules)(n)) + 1 /** * A set of arrays: `prefixItems` for the declared positions, `items` for what * may follow — `false` when nothing may, which is what makes the exact-length * pattern exact. `prefixItems` alone constrains only elements that exist * (draft 2020-12 implies no minimum length), so the required length is - * `minItems`, and a position past it — one the array may simply end before — - * has `undefined` stripped from its schema, absence being expressed by - * `minItems` already. Both are the object side's `required` / + * `minItems` — one past the last position excluding absence — and a position + * past it has `undefined` stripped from its schema, JSON spelling an + * unfilled position as `null`-less truncation rather than a written + * `undefined`. Both are the object side's `required` / * {@link stripUndefined} pair, one kind over. * * @type {(rules: RuleSet) => (p: ArraySet) => Unknown} @@ -209,20 +212,28 @@ const arraySetSchema = rules => p => { } } -/** Whether the node's value set admits `undefined` — its unit bit, read - * through a reference if needed. +/** + * Whether the node's set admits **absence** — its absent bit, read through a + * reference if needed. What drives `required` and `minItems`: absence is + * what lets a key or position be left out, so this is a different question + * from {@link stripUndefined}'s. + * * @type {(rules: RuleSet) => (n: Node) => boolean} */ -const admitsUndefined = rules => n => { +const admitsAbsence = rules => n => { const u = typeof n === 'string' ? assertNotNullish(at(n)(rules)) : n - return ((u.unit ?? 0) & undefinedBit) !== 0 + return ((u.unit ?? 0) & absentBit) !== 0 } /** - * The node with `undefined` removed — for an optional property's schema, - * where absence is already expressed by the key not being `required`. A - * reference is kept as-is: its definition is shared, and the extra - * `{ "not": {} }` member it may carry matches no JSON value anyway. + * The node with `undefined` removed — asking what JSON can **carry**, so it + * stays keyed on the `undefined` bit while `required`/`minItems` moved to + * the absent one. A key of `or(number, undefined)` is required and renders + * as `number`: JSON has no way to write the `undefined` case, so the + * rendering under-approximates — the same corner this module already + * documents for `NaN` and `-0`. A reference is kept as-is: its definition is + * shared, and the extra `{ "not": {} }` member it may carry matches no JSON + * value anyway. * * @type {(n: Node) => Node} */ @@ -231,16 +242,17 @@ const stripUndefined = n => /** * A set of objects: `properties` for the declared keys — a key admitting - * `undefined` is optional and has `undefined` stripped from its schema, - * every other key is `required` — and `additionalProperties` for the rest. - * No `rest` leaves the other keys unconstrained (lenient), matching rtti's - * open-struct validation semantics. + * **absence** is left out of `required`, every key has `undefined` stripped + * from its printed schema ({@link stripUndefined}, JSON carrying no + * `undefined`) — and `additionalProperties` for the rest. No `rest` leaves + * the other keys unconstrained (lenient), matching rtti's open-struct + * validation semantics. * * @type {(rules: RuleSet) => (p: ObjectSet) => Unknown} */ const objectSetSchema = rules => p => { const ents = definedEntries(p.props) - const required = ents.filter(([, n]) => !admitsUndefined(rules)(n)).map(([k]) => k) + const required = ents.filter(([, n]) => !admitsAbsence(rules)(n)).map(([k]) => k) return { type: 'object', ...(ents.length === 0 ? {} : { @@ -255,8 +267,16 @@ const objectSetSchema = rules => p => { /** @type {(u: UnionSet) => boolean} */ const isTop = u => cmp([{}, u])([{}, top]) === 0 -/** @type {(rules: RuleSet) => (u: UnionSet) => Unknown} */ -const unionSchema = rules => u => { +/** + * The absent bit is masked before rendering: absence is not a JSON value — + * it is spelled by a key's omission from `required`, or by `minItems` — so + * it contributes no schema member, and `or(option, unknown)` is the + * always-true `{}` like plain `unknown`. + * + * @type {(rules: RuleSet) => (u: UnionSet) => Unknown} + */ +const unionSchema = rules => u0 => { + const u = withoutUnits(absentBit)(u0) if (isTop(u)) { return {} } const members = [ ...unitSchemas(u.unit ?? 0), diff --git a/fjs/media/json/schema/proof.f.mjs b/fjs/media/json/schema/proof.f.mjs index 041bbf393..9e5639902 100644 --- a/fjs/media/json/schema/proof.f.mjs +++ b/fjs/media/json/schema/proof.f.mjs @@ -6,7 +6,7 @@ import { boolean, number, string, bigint, never, unknown, array, open, record, or, option } from '../../../rtti/module.f.mjs' import { stringify } from '../module.f.mjs' import { dataToJsonSchema, toJsonSchema, unknown as schemaUnknown } from './module.f.mjs' -import { unitBit } from '../../../rtti/data/module.f.mjs' +import { absentBit, unitBit } from '../../../rtti/data/module.f.mjs' import { assert, assertEq } from '../../../asserts/module.f.mjs' /** @type {(v: Unknown) => string} */ @@ -91,13 +91,13 @@ export const proof = { minItems: 2, items: false, }), - withOptional: eq(/** @type {const} */ ([number, option(string)]), { + withOptional: eq(/** @type {const} */ ([number, or(option, string)]), { type: 'array', prefixItems: [{ type: 'number' }, { type: 'string' }], minItems: 1, items: false, }), - allOptional: eq(/** @type {const} */ ([option(number)]), { + allOptional: eq(/** @type {const} */ ([or(option, number)]), { type: 'array', prefixItems: [{ type: 'number' }], items: false, @@ -118,13 +118,13 @@ export const proof = { required: ['x', 'y'], additionalProperties: { not: {} }, }), - withOptional: eq(/** @type {const} */ ({ x: number, y: option(string) }), { + withOptional: eq(/** @type {const} */ ({ x: number, y: or(option, string) }), { type: 'object', properties: { x: { type: 'number' }, y: { type: 'string' } }, required: ['x'], additionalProperties: { not: {} }, }), - allOptional: eq(/** @type {const} */ ({ x: option(number) }), { + allOptional: eq(/** @type {const} */ ({ x: or(option, number) }), { type: 'object', properties: { x: { type: 'number' } }, additionalProperties: { not: {} }, @@ -141,11 +141,20 @@ export const proof = { properties: { x: { type: 'number' }, y: { type: 'string' } }, required: ['x', 'y'], }), - orOptional: eq(/** @type {const} */ ({ x: or(string, number, undefined) }), { + orOptional: eq(/** @type {const} */ ({ x: or(option, string, number) }), { type: 'object', properties: { x: { anyOf: [{ type: 'number' }, { type: 'string' }] } }, additionalProperties: { not: {} }, }), + // a present `undefined` no longer spells optionality: the key is + // required, and `stripUndefined` under-approximates its schema — + // JSON has no way to write the `undefined` case + orPresentUndefined: eq(/** @type {const} */ ({ x: or(string, undefined) }), { + type: 'object', + properties: { x: { type: 'string' } }, + required: ['x'], + additionalProperties: { not: {} }, + }), withConst: eq(/** @type {const} */ ({ x: null, y: string }), { type: 'object', properties: { x: { const: null }, y: { type: 'string' } }, @@ -153,12 +162,12 @@ export const proof = { additionalProperties: { not: {} }, }), optionalOfEveryKind: eq(/** @type {const} */ ({ - a: option(number), - b: option(string), - c: option(bigint), - d: option(array(number)), - e: option(record(string)), - f: or(null, undefined), + a: or(option, number), + b: or(option, string), + c: or(option, bigint), + d: or(option, array(number)), + e: or(option, record(string)), + f: or(option, null), }), { type: 'object', properties: { @@ -184,7 +193,7 @@ export const proof = { orWithConst: eq(or(null, string, 42), { anyOf: [{ const: null }, { const: 42 }, { type: 'string' }], }), - structWithOr: eq(/** @type {const} */ ({ id: or(string, number), name: option(string) }), { + structWithOr: eq(/** @type {const} */ ({ id: or(string, number), name: or(option, string) }), { type: 'object', properties: { id: { anyOf: [{ type: 'number' }, { type: 'string' }] }, @@ -260,7 +269,7 @@ export const proof = { $ref: '#/$defs/rec', $defs: { rec: { type: 'object', additionalProperties: { $ref: '#/$defs/rec' } } }, }), - optionalRecursiveProperty: eq(/** @type {const} */ ({ p: option(list) }), { + optionalRecursiveProperty: eq(/** @type {const} */ ({ p: or(option, list) }), { type: 'object', properties: { p: { type: 'array', items: listRef } }, additionalProperties: { not: {} }, @@ -305,7 +314,7 @@ export const proof = { }] /** @type {Data} */ const optionalByReference = [ - { r: { unit: unitBit(null) | unitBit(undefined), number: true } }, + { r: { unit: unitBit(null) | absentBit, number: true } }, { object: [{ props: { p: 'r' } }] }, ] return { @@ -322,12 +331,14 @@ export const proof = { required: ['a'], additionalProperties: { type: 'string' }, }), - // a referenced definition admitting `undefined` makes the key - // optional; the reference itself is kept as the property schema + // a referenced definition admitting absence makes the key + // optional; the reference itself is kept as the property schema, + // and the absent bit is masked from the definition — absence is + // spelled by the key's omission from `required`, not by a member optionalByReference: eqData(optionalByReference, { type: 'object', properties: { p: { $ref: '#/$defs/r' } }, - $defs: { r: { anyOf: [{ const: null }, { not: {} }, { type: 'number' }] } }, + $defs: { r: { anyOf: [{ const: null }, { type: 'number' }] } }, }), } })(), diff --git a/fjs/media/note/README.md b/fjs/media/note/README.md index 7729956fd..707f65709 100644 --- a/fjs/media/note/README.md +++ b/fjs/media/note/README.md @@ -23,8 +23,8 @@ export const priorities = ['P1', 'P2', 'P3', 'P4', 'P5'] as const export const noteSchema = { dialect: 'vnd.fjs.note', text: string, - dependencies: option(array(string)), - priority: option(or(...priorities)), + dependencies: or(option, array(string)), + priority: or(option, ...priorities), } as const ``` diff --git a/fjs/media/note/module.f.mjs b/fjs/media/note/module.f.mjs index 05d9d615e..7638cb723 100644 --- a/fjs/media/note/module.f.mjs +++ b/fjs/media/note/module.f.mjs @@ -101,8 +101,8 @@ export const priorities = /** @type {const} */ (['P1', 'P2', 'P3', 'P4', 'P5']) export const noteSchema = open(/** @type {const} */ ({ dialect, text: string, - dependencies: option(array(string)), - priority: option(or(...priorities)), + dependencies: or(option, array(string)), + priority: or(option, ...priorities), })) /** Serializes a note canonically, sorting every object's property names. diff --git a/fjs/media/note/todo/extend-note-format.md b/fjs/media/note/todo/extend-note-format.md index 4488b721b..c64c06293 100644 --- a/fjs/media/note/todo/extend-note-format.md +++ b/fjs/media/note/todo/extend-note-format.md @@ -21,10 +21,10 @@ the field forces a new dialect) and the Candidates, roughly in order of usefulness: -- `title: option(string)` — a short summary line (issues, events). Decide +- `title: or(option, string)` — a short summary line (issues, events). Decide whether an absent title and `''` collapse into one meaning, or whether the field should reject `''` the way `lock` blobs treat emptiness. -- `tags: option(array(string))` — free-form labels. Absent and `[]` are two +- `tags: or(option, array(string))` — free-form labels. Absent and `[]` are two spellings of "no tags"; decide which one canonical writers emit, or make the field's presence require a non-empty array. - Event fields — `start` / `end` times. Needs a time representation decision diff --git a/fjs/media/revision/README.md b/fjs/media/revision/README.md index 1dc03a5ac..73eb909ce 100644 --- a/fjs/media/revision/README.md +++ b/fjs/media/revision/README.md @@ -21,8 +21,8 @@ export const revisionSchema = { parents: array(hash), snapshot: hash, generation: number, - archived: option(true), - lock: option(lockField), + archived: or(option, true), + lock: or(option, lockField), } as const export const lock = () => ['record', lockValue] as const @@ -174,7 +174,7 @@ absent value is a constant default.** `snapshot` and `generation` are required because their absence would force inference (a resolution algorithm and an ancestry walk, respectively). `archived` is the documented boundary of the rule and stays **optional**: its absence is the constant `false`, derivable -from nothing, so the `option(true)` presence-flag idiom is exactly right — +from nothing, so the `or(option, true)` presence-flag idiom is exactly right — forcing `archived: false` onto every blob would be pure noise. Inference has not disappeared; it moved to the write boundary. The `evo_add` @@ -354,8 +354,8 @@ section of [fjs/cas/evo/README.md](../../cas/evo/README.md). `archived` marks a mutable object as no longer worked on (e.g. a finished task); its blobs can be deleted from a local CAS after a backup. It follows -the existing `option(true)` idiom (a presence-only flag) rather than -`option(boolean)`. +the existing `or(option, true)` idiom (a presence-only flag) rather than +`or(option, boolean)`. ## Out of scope (this module) diff --git a/fjs/media/revision/module.f.mjs b/fjs/media/revision/module.f.mjs index 5190721f6..b03e24267 100644 --- a/fjs/media/revision/module.f.mjs +++ b/fjs/media/revision/module.f.mjs @@ -21,7 +21,7 @@ * @import { LockField, LockFieldSchema, LockMap, LockSchema, Revision, RevisionError } from './types.ts' */ -import { array, number, open, option, string } from '../../rtti/module.f.mjs' +import { array, number, open, option, or, string } from '../../rtti/module.f.mjs' import { parse as rttiParse } from '../../rtti/parse/module.f.mjs' import { parse as parseJson } from '../json/module.f.mjs' import { cBase32ToVec } from '../../basen/cbase32/module.f.mjs' @@ -126,8 +126,8 @@ export const revisionSchema = open(/** @type {const} */ ({ parents: array(hash), snapshot: hash, generation: number, - archived: option(true), - lock: option(lockField), + archived: or(option, true), + lock: or(option, lockField), })) /** Serializes a revision canonically, recursively sorting every object's property names. diff --git a/fjs/media/revision/proof.f.mjs b/fjs/media/revision/proof.f.mjs index 5ed61e3da..a0c4bad44 100644 --- a/fjs/media/revision/proof.f.mjs +++ b/fjs/media/revision/proof.f.mjs @@ -122,7 +122,7 @@ export const proof = { assertEq(t, 'error') }, - // `archived` follows the presence-only `option(true)` idiom. + // `archived` follows the presence-only `or(option, true)` idiom. archivedAccepted: () => { const [t] = validate(revisionOf({ archived: true })) assertEq(t, 'ok') diff --git a/fjs/protocol/json_rpc/module.f.mjs b/fjs/protocol/json_rpc/module.f.mjs index e1cc85d16..275b3dabe 100644 --- a/fjs/protocol/json_rpc/module.f.mjs +++ b/fjs/protocol/json_rpc/module.f.mjs @@ -42,15 +42,15 @@ export const _id = or(string, number, null) export const request = open(/** @type {const} */ ({ jsonrpc, method: string, - params: option(unknown), - id: option(_id), + params: or(option, unknown), + id: or(option, _id), })) /** The JSON-RPC error object — `open`, for the reason {@link request} gives. */ export const error = open(/** @type {const} */ ({ code: number, message: string, - data: option(unknown), + data: or(option, unknown), })) export const successResponse = open(/** @type {const} */ ({ jsonrpc, result: unknown, id: _id })) diff --git a/fjs/protocol/mcp/README.md b/fjs/protocol/mcp/README.md index c9ad7026b..fac0eb0ea 100644 --- a/fjs/protocol/mcp/README.md +++ b/fjs/protocol/mcp/README.md @@ -65,10 +65,10 @@ export const fromRegistry = ( Define argument schemas as RTTI: ```ts -import { string, number, option } from '../../rtti/module.f.mjs' +import { string, number, option, or } from '../../rtti/module.f.mjs' const addArgs = { a: number, b: number } as const -const greetArgs = { name: string, greeting: option(string) } as const +const greetArgs = { name: string, greeting: or(option, string) } as const ``` Create tool entries with type-safe handlers: diff --git a/fjs/protocol/mcp/module.f.mjs b/fjs/protocol/mcp/module.f.mjs index ebcc028fd..6ad6da56b 100644 --- a/fjs/protocol/mcp/module.f.mjs +++ b/fjs/protocol/mcp/module.f.mjs @@ -51,11 +51,11 @@ export const implementation = open(/** @type {const} */ ({ // ── Capabilities ─────────────────────────────────────────────────────────────── -const toolsCapability = open(/** @type {const} */ ({ listChanged: option(boolean) })) +const toolsCapability = open(/** @type {const} */ ({ listChanged: or(option, boolean) })) /** Server capabilities advertised in the `initialize` response. */ export const serverCapabilities = open(/** @type {const} */ ({ - tools: option(toolsCapability), + tools: or(option, toolsCapability), })) // ── Lifecycle ────────────────────────────────────────────────────────────────── @@ -72,7 +72,7 @@ export const initializeResult = open(/** @type {const} */ ({ protocolVersion: string, capabilities: serverCapabilities, serverInfo: implementation, - instructions: option(string), + instructions: or(option, string), })) // ── Content ──────────────────────────────────────────────────────────────────── @@ -88,7 +88,7 @@ export const textContent = open(/** @type {const} */ ({ type: 'text', text: stri */ export const blobResource = open(/** @type {const} */ ({ uri: string, - mimeType: option(string), + mimeType: or(option, string), blob: string, })) @@ -114,7 +114,7 @@ export const contentItem = or(textContent, embeddedResource) */ export const tool = open(/** @type {const} */ ({ name: string, - description: option(string), + description: or(option, string), inputSchema: unknown, })) @@ -123,22 +123,22 @@ export const tool = open(/** @type {const} */ ({ * from a previous `ToolsListResult.nextCursor`. */ export const toolsListParams = open(/** @type {const} */ ({ - cursor: option(string), + cursor: or(option, string), })) export const toolsListResult = open(/** @type {const} */ ({ tools: array(tool), - nextCursor: option(string), + nextCursor: or(option, string), })) export const toolsCallParams = open(/** @type {const} */ ({ name: string, - arguments: option(record(unknown)), + arguments: or(option, record(unknown)), })) export const toolsCallResult = open(/** @type {const} */ ({ content: array(contentItem), - isError: option(boolean), + isError: or(option, boolean), })) // ── Dispatch ─────────────────────────────────────────────────────────────────── @@ -260,8 +260,11 @@ export const fromRegistry = registry => ({ export const notInitialized = rpcError(-32002)('Server not initialized') // Params for methods that take no arguments (`ping`, `notifications/initialized`): -// absent, or an object (which may carry `_meta`). -const _noParams = option(record(unknown)) +// absent, or an object (which may carry `_meta`). Checked against the *read* +// `message.params`, a top-level value — absence has already become the read +// `undefined` by then, so the union carries `undefined` the value, not +// `option`: at the entry position nothing can be absent. +const _noParams = or(record(unknown), undefined) /** Initial session state — always start here. */ /** @type {McpSessionState} */ diff --git a/fjs/rtti/README.md b/fjs/rtti/README.md index fce9dd4bb..e88dd0ea6 100644 --- a/fjs/rtti/README.md +++ b/fjs/rtti/README.md @@ -97,7 +97,7 @@ value carrying more is not one of its values, on either reader: | `{ a: 42 }` | `{ a: 42, b: 'x' }` | error | error | | `{ a: 42 }` | `{ a: 42 }` | `{ a: 42 }` | `{ a: 42 }` | | `[42]` | `[42, 'extra']` | error | error | -| `[number, option(string)]` | `[42]` | `[42, undefined]` | `[42]` | +| `[number, or(option, string)]` | `[42]` | `[42]` | `[42]` | | `[42]` | `[]` | error | error | A tuple answers by **length** as well as by member: a hole past the prefix is @@ -105,13 +105,17 @@ no member, so `[42, , ]` would slip through a member check alone while the array is still that long. The last two rows are one rule, and closedness leaves it alone — it is about -*undeclared* members, and a declared position admitting `undefined` stays -omittable. An absent member reads as `undefined`, so a member is **required -exactly when its set excludes `undefined`**. Position 1 of -`[number, option(string)]` admits `undefined`, so a shorter array is fine — -`parse` fills the gap in what it builds, `validate` has nothing to fill — -while `42` excludes it, so position 0 of `[42]` is required and `[]` fails for -both. This is the same rule the data form states for object keys. +*undeclared* members, and a declared position admitting **absence** stays +omittable. A member is absent when its key or index is neither an own +property nor an inherited one, and it is **required exactly when its set +excludes absence** — the `option` member of its union. Position 1 of +`[number, or(option, string)]` admits absence, so a shorter array is fine — +and neither reader materializes anything: `parse` builds `[42]`, omitting the +absent member — while `42` excludes it, so position 0 of `[42]` is required +and `[]` fails for both. Absence is not a spelling of `undefined`: `{}` and +`{ a: undefined }` are two distinct values, `or(option, t)` admits the first +and `or(t, undefined)` the second. This is the same rule the data form +states for object keys, as the `absentBit` of a member's unit bitset. The data form says the same thing in its own vocabulary — a bare container's `rest` is `never` on both kinds — so `validate(toData(s))` accepts exactly what @@ -274,6 +278,7 @@ unary schemas (`array`, `record`) return `Info1` (a tag + inner type tuple). | `string` | `['string']` | any `string` | | `bigint` | `['bigint']` | any `bigint` | | `unknown` | `['unknown']` | any DJS value | +| `option` | `['option']` | nothing — **absence**: `or(option, t)` is a member that may be left out | | `array(t)` | `['array', t]` | `readonly Ts[]` | | `record(t)` | `['record', t]` | `{ readonly[K: string]: Ts }` | | `rest(c, r)` | `['rest', c, r]` | `c`'s members, and only members of `r` besides | diff --git a/fjs/rtti/common/module.f.mjs b/fjs/rtti/common/module.f.mjs index f6467e383..e0bdd519f 100644 --- a/fjs/rtti/common/module.f.mjs +++ b/fjs/rtti/common/module.f.mjs @@ -285,6 +285,51 @@ export const undeclaredMembers = (declared, value) => { ] } +/** + * Whether `rtti` admits **absence** with `visited` already ruled out — the + * recursive half of {@link admitsAbsence}, carrying the thunks on the current + * path so a recursive union such as `X = or(X, option)` terminates. + * + * @type {(visited: readonly Type[], rtti: Type) => boolean} + */ +const absenceIn = (visited, rtti) => { + if (typeof rtti !== 'function') { return false } + if (visited.some(v => v === rtti)) { return false } + const [tag, ...operands] = rtti() + if (tag === 'option') { return true } + if (tag !== 'or') { return false } + return operands.some(op => absenceIn([...visited, rtti], op)) +} + +/** + * Whether the schema admits **absence** — whether `option` is reachable + * through its unions, so a container may leave the member out entirely. + * + * This is the container loop's question, asked *before* dispatch: a + * recursive reader is handed only the value read, and an absent key reads + * `undefined`, so absence cannot be decided downstream of the read. The + * predicate traverses nested `or` nodes — the schema-form `or` does no + * flattening, so `or(or(option, number), string)` has no `option` among its + * direct members while admitting absence — descends the thunks they hold, + * stops at any other tag, and carries the visited thunks to terminate on a + * recursive `X = or(X, option)`. The data form needs no such traversal: + * `toData` has already flattened, so its readers test one unit bit. + * + * @type {(rtti: Type) => boolean} + */ +export const admitsAbsence = rtti => absenceIn([], rtti) + +/** + * The shared answer for a declared member that is not there — no own or + * inherited key at its position: the member is legal exactly when its schema + * admits absence. The `ok` payload is unused by pass/fail callers and is not + * a value read from the container, absence being the whole point. + * + * @type {(rtti: Type) => ResultE} + */ +export const absentMember = rtti => + admitsAbsence(rtti) ? ok(undefined) : verror('unexpected value') + /** * First variant in `variants` that `recurse` accepts, else `verror('no match')`. * @@ -338,6 +383,7 @@ export const visit = case 'array': return v.array(value[0]) case 'record': return v.record(value[0]) case 'unknown': return v.unknown() + case 'option': return v.option() case 'or': return v.or(value) case 'rest': { const [c, r] = value diff --git a/fjs/rtti/common/types.ts b/fjs/rtti/common/types.ts index 50805cb3f..8c6055200 100644 --- a/fjs/rtti/common/types.ts +++ b/fjs/rtti/common/types.ts @@ -45,6 +45,15 @@ export type Visitor = { readonly constPrimitive: (p: Primitive) => R readonly primitive0: (tag: Primitive0) => R readonly unknown: () => R + /** + * The nullary `option` schema — absence. A reader's handler *rejects* + * normally: absence is decided by the container loop before dispatch + * (see `admitsAbsence` in `./module.f.mjs`), so a value that reaches a + * recursive reader is present by construction, and under `or(option, t)` + * the `option` branch has to return an ordinary error for `t` to be + * tried. + */ + readonly option: () => R } /** diff --git a/fjs/rtti/data/README.md b/fjs/rtti/data/README.md index 77918e265..4681f1731 100644 --- a/fjs/rtti/data/README.md +++ b/fjs/rtti/data/README.md @@ -36,7 +36,7 @@ are kind-wise: | kind | representation | notes | | -------- | --------------------------------------- | -------------------------------------------- | -| `unit` | bitset over `null, undefined, false, true` | `or(true, false)` is the two boolean bits — "boolean" needs no special rule | +| `unit` | bitset over `null, undefined, false, true`, plus the `absentBit` | `or(true, false)` is the two boolean bits — "boolean" needs no special rule; bit `16` is **absence**, rtti's `option`, which is no DJS value and so no `unitList` member | | `number` | `true` (all) or sorted literals | SameValue semantics: `-0 ≠ 0`, `NaN` allowed | | `string` | `true` or sorted literals | | | `bigint` | `true` or sorted literals | | @@ -46,8 +46,9 @@ are kind-wise: **Arrays and tuples share one kind** because their value sets overlap: a tuple is an array whose leading positions carry distinct element types. The shared pattern is a tuple-with-rest: a `prefix` entry constrains the value *read* at -that position — reading past the array's end yields `undefined`, so a position -is required exactly when its set excludes `undefined` — and `rest` constrains +that position and whether one must be there — a position past the array's end, +or a hole, is **absent**, so a position is required exactly when its set +excludes the `absentBit` — and `rest` constrains every position after the prefix, admitting nothing there when it is absent. A bare tuple schema is `{ prefix }` alone — the exact-length set — an `open` one is `{ prefix, rest: unknown }`, and a uniform array is `{ prefix: [], rest }`. @@ -57,10 +58,10 @@ which the coverage collapse uses to drop `open([number, number])` from `{ a, b }` from `or(open({ a, b }), open({ a }))`. **Records and structs share one kind** for the same reason, and by the same -rule one kind over: a `props` entry constrains the value *read* at that key — -reading an absent key yields `undefined`, so a key is required exactly when -its set excludes `undefined`, and `option(t)` props are optional with no extra -mechanism. `rest` constrains the values at the remaining *present* keys; an +rule one kind over: a `props` entry constrains the value *read* at that key +and whether one must be there — a key is required exactly when its set +excludes the `absentBit`, so `or(option, t)` props are optional with no extra +mechanism and `{}` is told apart from `{ a: undefined }`. `rest` constrains the values at the remaining *present* keys; an `open` struct leaves them unconstrained (no `rest`), matching TypeScript's structural typing, and a bare, closed one says `rest: never`. @@ -94,9 +95,11 @@ disambiguated with a counter on collision. - array/object patterns are sorted, deduplicated, and *coverage-collapsed*: a pattern included in a sibling pattern is dropped; - degenerate patterns are simplified: an empty position empties the pattern, - an identity `rest`/prop disappears, a trailing position restating a `rest` - that admits absence is dropped, and a pattern constraining nothing is its - whole kind — `array(unknown)`, `[]` and `[unknown]` are one `Node`; + an identity `rest`/prop disappears, an inline `rest` is stripped of the + `absentBit` (a rest never sees an absent member), a trailing position that + admits absence and restates the `rest` is dropped, and a pattern + constraining nothing is its whole kind — `array(unknown)`, `open([])` and + `open([or(option, unknown)])` are one `Node`; - pure `or` cycles dissolve (`X = number | X` is `number` — the least fixpoint), rules are pruned to the reachable set and sorted, and an entry rule nothing else references is inlined; @@ -162,7 +165,7 @@ now. A bare `Tuple` schema is **closed** on all three readers, and says so here as `{ prefix }` with no `rest`: nothing past the prefix, so the array is at most `prefix.length` long — and at least as long as its last position excluding -`undefined` (see +absence (see [Structs and tuples are closed](../README.md#structs-and-tuples-are-closed)). `open(c)` is the thunk-form schema that widens it, converting to a `rest` of `unknown`, and `rest(c, R)` to that `R`; a `rest` of `never` normalizes back to @@ -173,8 +176,8 @@ no `rest` at all on this kind, so `rest(c, never)` and the bare `c` are one parse([42])([42, 'extra']) // ['error', …] parse(open([42]))([42, 'extra']) // ['ok', [42]] validate(toData(open([42])))([42, 'extra']) // ['ok', [42, 'extra']] -parse([number, option(string)])([42]) // ['ok', [42, undefined]] -validate(toData([number, option(string)]))([42]) // ['ok', [42]] +parse([number, or(option, string)])([42]) // ['ok', [42]] +validate(toData([number, or(option, string)]))([42]) // ['ok', [42]] ``` `../validate/proof.f.mjs` runs one acceptance table through all three readers, @@ -191,9 +194,24 @@ the `rest` is gone, since an undeclared key may be absent or else must belong to present, `{ props: { a: unknown }, rest: never }` (objects with at most the key `a`) and `{ props: {}, rest: never }` (the empty object) are two different sets. -Note the asymmetry that phrasing preserves: a *declared* key constrains the -value **read** at it, so an absent one reads `undefined` and is admitted when -the declared set holds `undefined`. An *undeclared* key is checked as an -**entry**, so a present `b: undefined` must satisfy `rest` itself rather than -being excused by its absence — `{ props: { a: number }, rest: string }` rejects -`{ a: 1, b: undefined }` and accepts `{ a: 1 }`. +Note the symmetry stage 2 of `option`-as-omission completed: a *declared* +key is admitted absent exactly when its set carries the `absentBit`, and a +present `undefined` there must be a member of the set as a value. An +*undeclared* key is checked as an **entry**, so a present `b: undefined` must +satisfy `rest` itself — `{ props: { a: number }, rest: string }` rejects +`{ a: 1, b: undefined }` and accepts `{ a: 1 }` — and a missing one is no +entry at all. Absence is describable on both sides. + +Two more structural incompletenesses join the rule-name one above, both from +the referenced-`rest` exemption: an inline `rest` is stripped of the +`absentBit` while a **referenced** one is left alone (the same rule may be +used at a declared position, where the bit is live, and for a recursive rule +the stripped form is a different fixpoint, not a bit-mask — materializing it +would be the bisimulation-grade work this form avoids), and the same +exemption covers a referenced **trailing position** that restates its rest. +Such a pair denotes one set spelled two ways where the reference's present +part is non-empty — mutual `subset`s, structurally distinct — and two +genuinely different sets where it is empty: an absence-only referenced rest +admits any hole-only array, while its stripped form bounds the length. That +last case is why `subset` **resolves** a referenced rest rather than masking +its bit: a mask would answer `true` for that non-inclusion. diff --git a/fjs/rtti/data/module.f.mjs b/fjs/rtti/data/module.f.mjs index 25f00c18e..14a137eb6 100644 --- a/fjs/rtti/data/module.f.mjs +++ b/fjs/rtti/data/module.f.mjs @@ -30,12 +30,19 @@ import { eachEntry, isArray, undeclaredMembers, verror } from '../common/module. /** * The unit kind's enumeration: bit `1 << i` of a {@link UnionSet}'s `unit` * bitset stands for `unitList[i]`. + * + * {@link absentBit} is the one `unit` bit with no `unitList` entry: absence + * is not a DJS value, so it has nothing to enumerate here — see the bit's + * own doc, and `UnionSet` in `./types.ts` for the serialized contract. */ export const unitList = /** @type {const} */ (['null', 'undefined', 'false', 'true']) /** * The `unit` bit of one unit value. * + * Value-keyed, so it cannot answer for {@link absentBit}: the absent bit has + * no JS value to key on — absence is the member that is not there. + * * @type {(v: null | undefined | boolean) => number} */ export const unitBit = v => @@ -43,6 +50,18 @@ export const unitBit = v => v === undefined ? 2 : v ? 8 : 4 +/** + * The fifth `unit` bit: **absence**, rtti's nullary `option`. Not a member + * of {@link unitList}, because it is not a DJS value — no value reads as + * absent; a *container position* is absent by having no own or inherited + * key. The set algebra does not care: union, `subset`, `cmp`, `equal` and + * the coverage collapse are bitwise over the unit kind, so the bit rides + * along. What does care is normalization — a `rest` never sees absence, so + * an inline rest is stripped of the bit ({@link arraySet}/{@link objectSet}) + * — and the readers, which test it where a declared member is missing. + */ +export const absentBit = 16 + const allUnits = unitBit(null) | unitBit(undefined) | unitBit(false) | unitBit(true) const booleanUnits = unitBit(false) | unitBit(true) @@ -287,49 +306,92 @@ const isNever = n => typeof n !== 'string' && cmpUnion(n, never) === 0 const isTop = n => typeof n !== 'string' && cmpUnion(n, unknown) === 0 /** - * The prefix with its redundant tail removed: a last position stating exactly - * the `rest` says nothing the `rest` does not already say, *provided* the - * `rest` admits `undefined`. - * - * Every array carrying a value at that position is read against the same set - * either way, so the two spellings can only differ on the arrays with nothing - * there — one that ends before it, and one holding a hole at it. Both read - * `undefined`, which the `rest` alone imposes nothing on, so dropping the - * position widens the set unless the `rest` admits `undefined` too. That is - * why `{ prefix: [number], rest: number }` keeps its position and stays "one - * or more numbers": `[]` and `[ , 1]` belong to `{ prefix: [], rest: number }` - * and not to it. + * The **declared-member** top: any value, or nothing — `or(option, unknown)`. + * A declared position is where absence is observable, so its top carries the + * absent bit; a `rest`'s top is plain {@link unknown}, a rest never seeing + * an absent member. * - * This is what keeps one set to one spelling: the open tuples `[]` and - * `[unknown]` are both every array and have to produce one `Node`. + * @type {UnionSet} + */ +const declaredTop = { ...unknown, unit: allUnits | absentBit } + +/** @type {(n: Node) => boolean} */ +const isDeclaredTop = n => typeof n !== 'string' && cmpUnion(n, declaredTop) === 0 + +/** + * The node with the absent bit stripped — what a `rest` position normalizes + * an **inline** union to, absence being unobservable there: a declared + * member is checked as the value read at its position, but a `rest` is + * checked against each *present* member, so the bit in a rest constrains + * nothing. A **referenced** rest is left alone: the same rule may be used at + * a declared position, where the bit is live, so clearing it globally would + * delete optionality elsewhere — and the stripped form of a recursive rule + * is a different fixpoint, not a bit-mask (see `./README.md`). + * + * @type {(n: Node) => Node} + */ +const stripAbsent = n => + typeof n === 'string' ? n : withoutUnits(absentBit)(n) + +/** + * The prefix with its redundant tail removed: a trailing declared position + * that **admits absence** and whose absence-stripped set states exactly the + * `rest` says nothing the `rest` does not already say. * - * A referenced `rest` is left alone — reading its unit bits would need the - * rule set, and the form already declines to see through a reference (see - * `./README.md`). + * Every array carrying a value at that position is read against the same set + * either way, so the two spellings can only differ on the arrays with + * nothing there — one that ends before it, and one holding a hole at it. + * Both are the position *absent*, which the position must admit for either + * to belong; past the prefix a hole is no member, so the `rest` admits both + * for free. That is why `{ prefix: [number], rest: number }` keeps its + * position and stays "one or more numbers": `[]` and `[ , 1]` belong to + * `{ prefix: [], rest: number }` and not to it. + * + * This is what keeps one set to one spelling: `rest([or(option, number)], + * number)` and `array(number)` are both "arrays of numbers, any of which may + * be a hole" and have to produce one `Node`. + * + * Two exemptions. A **referenced** trailing position is left alone — reading + * its unit bits would need the rule set, and the form already declines to + * see through a reference (see `./README.md`). And the trim never sees an + * **empty** `rest` — {@link arraySet} returns the exact-length pattern + * before trimming — which is what keeps `[option]` (its sole position + * stripping to `never`, like the `rest`) distinct from `[]`: the two differ + * on `new Array(1)`, a length the first admits and the second bounds out. * * @type {(prefix: readonly Node[], rest: Node) => readonly Node[]} */ -const trimPrefix = (prefix, rest) => - typeof rest === 'string' || ((rest.unit ?? 0) & unitBit(undefined)) === 0 - ? prefix - : prefix.slice(0, prefix.findLastIndex(n => cmpNode(n, rest) !== 0) + 1) +const trimPrefix = (prefix, rest) => { + if (typeof rest === 'string') { return prefix } + /** @type {(n: Node) => boolean} */ + const redundant = n => + typeof n !== 'string' + && ((n.unit ?? 0) & absentBit) !== 0 + && cmpUnion(withoutUnits(absentBit)(n), rest) === 0 + return prefix.slice(0, prefix.findLastIndex(n => !redundant(n)) + 1) +} /** * Canonical array-kind singleton. A syntactically empty position makes the - * whole pattern empty (a position past the array's end reads as `undefined`, - * which the empty set excludes, so no length escapes it); an empty `rest` - * admits nothing past the prefix, which is what no `rest` already says; a - * prefix restating its `rest` is {@link trimPrefix}'d away; an unconstrained - * `rest` with nothing left before it is every array. + * whole pattern empty (nothing may be there and it may not be absent, so no + * array has such a position — and none is short enough to escape it, a + * missing index being absence); an inline `rest` is stripped of the absent + * bit ({@link stripAbsent} — a rest never sees an absent member); an empty + * `rest` admits nothing past the prefix, which is what no `rest` already + * says; a prefix restating its `rest` is {@link trimPrefix}'d away; an + * unconstrained `rest` with nothing left before it is every array. * * Every array set is stated with a `rest` — `never` for a bare tuple, * `unknown` for an `open` one, the element set for a uniform array — so this * takes one rather than an optional one; the absent `rest` is what it - * normalizes an empty one *to*. + * normalizes an empty one *to*. `array(option)` is therefore the empty + * array: its element set strips to `never`, and a `never` rest is the + * exact-length pattern of its (empty) prefix. * * @type {(prefix: readonly Node[], rest: Node) => UnionSet} */ -const arraySet = (prefix, rest) => { +const arraySet = (prefix, rest0) => { + const rest = stripAbsent(rest0) if (prefix.some(isNever)) { return never } if (isNever(rest)) { return { array: [{ prefix }] } } const p = trimPrefix(prefix, rest) @@ -337,24 +399,36 @@ const arraySet = (prefix, rest) => { } /** - * Canonical object-kind singleton. An unconstrained `rest` is the same set as - * no `rest`; an unconstrained key is then dropped too; a syntactically empty - * key set makes the whole pattern empty; with nothing left, the pattern is - * every object. + * Canonical object-kind singleton. An inline `rest` is stripped of the + * absent bit ({@link stripAbsent}); an unconstrained `rest` is the same set + * as no `rest`; an unconstrained key is then dropped too; a syntactically + * empty key set makes the whole pattern empty; with nothing left, the + * pattern is every object. * * A key is dropped only once the `rest` is gone, and that order is the whole * rule: an undeclared key may be absent, or must belong to `rest`, which * leaves it unconstrained exactly when there is no `rest` — so with one * present a key saying "anything" says strictly more than leaving it out. * A bare struct's empty `rest` is where the two part company — - * `{ props: { a: unknown }, rest: never }` admits `{ a: 1 }` and + * `{ props: { a: or(option, unknown) }, rest: never }` admits `{ a: 1 }` and * `{ props: {}, rest: never }` admits only `{}`. * - * @type {(props: readonly (readonly [string, Node])[], rest: Node | undefined) => UnionSet} + * "Unconstrained", for a declared key, is {@link isDeclaredTop} — anything + * *or nothing*, `or(option, unknown)` — not the plain top: a key declared + * `unknown` must be present, which an undeclared key need not be, so + * dropping it would widen the set. + * + * Like {@link arraySet}, every object set is stated with a `rest` — `never` + * for a bare struct, `unknown` for an `open` one, the value set for a + * uniform record — and the absent `rest` is what an unconstrained one + * normalizes *to*. + * + * @type {(props: readonly (readonly [string, Node])[], rest: Node) => UnionSet} */ -const objectSet = (props, rest) => { - const r = rest !== undefined && isTop(rest) ? undefined : rest - const constrained = r === undefined ? props.filter(([, v]) => !isTop(v)) : props +const objectSet = (props, rest0) => { + const rest = stripAbsent(rest0) + const r = isTop(rest) ? undefined : rest + const constrained = r === undefined ? props.filter(([, v]) => !isDeclaredTop(v)) : props if (constrained.some(([, v]) => isNever(v))) { return never } if (constrained.length === 0 && r === undefined) { return { object: true } } /** @type {StringMap} */ @@ -410,15 +484,35 @@ const kindSubset = le => (a, b) => { return a.every(x => b.some(y => le(x, y))) } +/** + * Whether the node's set carries the absent bit, read through a reference + * (own-property only). + * + * @type {(rules: RuleSet) => (n: Node) => boolean} + */ +const nodeAdmitsAbsence = rules => n => + ((resolve(rules)(n).unit ?? 0) & absentBit) !== 0 + /** * Only the *longest* array each side admits is tested here — `pn` without a * `rest`, unbounded with one. The shortest needs no test of its own: a - * position `q` insists on (one whose set excludes `undefined`) is a position - * `p` insists on too as soon as the pointwise check below passes, since - * otherwise `undefined` would be a member of `p.prefix[i]` and not of - * `q.prefix[i]`. Sound, and incomplete in the way `subset` is elsewhere: a - * `p` shorter than `q` is answered `false` even when every position past its - * end is one `q` admits as absent. + * position `q` insists on (one whose set excludes absence) is a position `p` + * insists on too as soon as the per-position check below passes, which is + * exactly what its absence-implication half states. Sound, and incomplete in + * the way `subset` is elsewhere: a `p` shorter than `q` is answered `false` + * even when every position past its end is one `q` admits as absent. + * + * A declared position asks the two questions the object kind asks of a key + * ({@link objectSetSubset}): what `p` may hold there must be something `q` + * holds there — the **absence-stripped** sets compared, absence not being a + * value — and `p` may leave the position out only where `q` lets it, which + * `q` does past its prefix (a hole there is no entry, so any `rest` admits + * it) or where its own position carries the bit. A left position that is a + * *reference* is compared unstripped — masking a reference is unsound, see + * `./README.md` — so such a pair is answered `false` unless the right + * carries the bit at that position: the accepted structural incompleteness. + * `p`'s own `rest` needs neither question, a rest carrying no absent bit + * after normalization. * * @type {(ctx: _Ctx) => (assumed: _Assumed) => (p: ArraySet, q: ArraySet) => boolean} */ @@ -432,7 +526,13 @@ const arraySetSubset = ctx => assumed => (p, q) => { if (!lengthOk) { return false } /** @type {(i: number) => Node} */ const qAt = i => i < qn ? q.prefix[i] : assertNotNullish(q.rest) - return p.prefix.every((el, i) => le(el, qAt(i))) + /** @type {(i: number) => boolean} */ + const qAdmitsAbsenceAt = i => i >= qn || nodeAdmitsAbsence(ctx[1])(q.prefix[i]) + return p.prefix.every((el, i) => + le(stripAbsent(el), qAt(i)) + && (typeof el === 'string' + || ((el.unit ?? 0) & absentBit) === 0 + || qAdmitsAbsenceAt(i))) && (p.rest === undefined || le(p.rest, assertNotNullish(q.rest))) } @@ -441,40 +541,41 @@ const keyed = n => [n, typeof n === 'string' ? `r:${n}` : undefined] /** * The set of values the pattern admits at key `k` when the key is **present**: - * the declared set, else the `rest`, else anything. - * - * Presence is the whole point of splitting this from {@link objectMayOmit}. An - * absent key and a key present holding `undefined` are not the same object, and - * the two sides of a pattern read them differently: a *declared* key constrains - * the value read at it, so absence reads `undefined` and passes when the set - * holds it, whereas an *undeclared* key is checked as an entry, so a present - * `undefined` must belong to `rest` itself (see {@link objectSetValidate}). - * Folding the two into one "read set" of `rest ∪ undefined` made - * `{ a: option(number) }` a subset of `record(number)`, which admits - * `{ a: undefined }` on the left and rejects it on the right. + * the declared set with its absent bit stripped, else the `rest`, else + * anything. + * + * Presence is the whole point of splitting this from {@link objectMayOmit}: + * this answers "what may be *present* at this key", that one answers whether + * the key may be missing, and {@link objectSetSubset} asks both. Absence is + * not a value, so a declared set's absent bit does not belong here — left + * unstripped, the closed `{ a: or(option, number) }` tested + * `(Absent | number) ⊆ number` against `record(number)` and answered `false` + * though its only values are `{}` and `{ a: number }`, both of which + * `record(number)` admits. A declared *reference* is kept unstripped — + * masking a reference is unsound (see `./README.md`) — the same structural + * incompleteness a referenced rest accepts. * * @type {(pattern: ObjectSet) => (k: string) => _Keyed} */ const objectPresentSet = pattern => k => { const n = at(k)(pattern.props) - if (n !== null) { return keyed(n) } + if (n !== null) { return keyed(stripAbsent(n)) } const { rest } = pattern return rest === undefined ? [unknown, 't'] : keyed(rest) } /** * Whether the pattern admits an object carrying no `k` at all: an undeclared - * key may always be missing, and a declared one exactly when its set holds - * `undefined`, since an absent property reads as `undefined`. + * key may always be missing, and a declared one exactly when its set carries + * the **absent bit**. * - * This is the half of the old read-set that the `∪ undefined` stood for, now - * asked as its own question — a local unit-bit test, so it needs no memo. + * The other half of the split — a local unit-bit test, so it needs no memo. * * @type {(rules: RuleSet) => (pattern: ObjectSet) => (k: string) => boolean} */ const objectMayOmit = rules => pattern => k => { const n = at(k)(pattern.props) - return n === null || ((resolve(rules)(n).unit ?? 0) & unitBit(undefined)) !== 0 + return n === null || nodeAdmitsAbsence(rules)(n) } /** @type {(list: readonly string[]) => readonly string[]} */ @@ -900,6 +1001,11 @@ const thunkUnion = (state, t) => { case 'string': { return [state, { string: true }] } case 'bigint': { return [state, { bigint: true }] } case 'unknown': { return [state, unknown] } + // An explicit case: the `default` arm below is `orUnion`, and a + // nullary tag has an empty operand list, so without it + // `toData(option)` would be the empty union — `never` — and + // `toData(or(option, t))` would silently lose the bit. + case 'option': { return [state, { unit: absentBit }] } case 'array': { const [state1, item] = nodeOf(state)(rest[0]) return [state1, arraySet([], item)] @@ -1117,12 +1223,16 @@ const patternsValidate = (k, item, value) => { } /** - * The declared positions are checked by reading the value at each — a - * position past the end reads as `undefined`, so a position is required - * exactly when its set excludes `undefined`, and no minimum length is tested - * for. What is left over is tested against `rest`, or, with no `rest`, must - * not be there at all. Same shape as {@link objectSetValidate}, one kind - * over. + * The declared positions are checked with absence decided **before** + * dispatch — an index that is neither an own property nor an inherited one + * is a missing member, legal exactly when its set carries the absent bit; + * a present one is checked as the value read. No minimum length is tested + * for: a too-short array is caught by the absence test at the first + * position that excludes it. What is left over is tested against `rest`, + * or, with no `rest`, must not be there at all. Same shape as + * {@link objectSetValidate}, one kind over — and the same before-dispatch + * test the schema-form readers make, so the three readers agree on `{}` + * versus `{ a: undefined }` and on sparse tuples. * * `undeclaredMembers` is what the schema-form readers walk too, so "what is * left over" is one rule rather than two that happen to coincide — including @@ -1136,7 +1246,9 @@ const arraySetValidate = rules => p => value => { const { rest } = p const declared = eachEntry( Object.entries(p.prefix), - (k, n) => nodeValidate(rules)(n)(value[Number(k)]), + (k, n) => k in value + ? nodeValidate(rules)(n)(value[Number(k)]) + : nodeAdmitsAbsence(rules)(n) ? ok(undefined) : verror('unexpected value'), undefined, noAccumulate, ) @@ -1161,7 +1273,9 @@ const arraySetValidate = rules => p => value => { const objectSetValidate = rules => p => value => { const declared = eachEntry( definedEntries(p.props), - (k, n) => nodeValidate(rules)(n)(value[k]), + (k, n) => k in value + ? nodeValidate(rules)(n)(value[k]) + : nodeAdmitsAbsence(rules)(n) ? ok(undefined) : verror('unexpected value'), undefined, noAccumulate, ) diff --git a/fjs/rtti/data/proof.f.mjs b/fjs/rtti/data/proof.f.mjs index aa0c1bb9f..56a589252 100644 --- a/fjs/rtti/data/proof.f.mjs +++ b/fjs/rtti/data/proof.f.mjs @@ -1,5 +1,5 @@ /** - * @import { Or } from '../types.ts' + * @import { Option, Or } from '../types.ts' * @import { Data } from './types.ts' */ @@ -18,7 +18,7 @@ import { string, unknown as unknownRtti, } from '../module.f.mjs' -import { cmp, equal, never, subset, toData, unitBit, unitList, unknown, validate, withoutUnits } from './module.f.mjs' +import { absentBit, cmp, equal, never, subset, toData, unitBit, unitList, unknown, validate, withoutUnits } from './module.f.mjs' /** @type {(actual: Data) => (expected: Data) => void} */ const assertData = actual => expected => @@ -94,12 +94,12 @@ const b2 = () => ['array', b2] const recordSelf = () => ['record', recordSelf] /** Mutual recursion through object *properties* rather than containers. */ -/** @typedef {() => readonly ['const', { readonly value: typeof number, readonly next: Or }]} _Even */ -/** @typedef {() => readonly ['const', { readonly value: typeof number, readonly next: Or }]} _Odd */ +/** @typedef {() => readonly ['const', { readonly value: typeof number, readonly next: Or }]} _Even */ +/** @typedef {() => readonly ['const', { readonly value: typeof number, readonly next: Or }]} _Odd */ /** @type {_Even} */ -const even = () => ['const', { value: number, next: option(odd) }] +const even = () => ['const', { value: number, next: or(option, odd) }] /** @type {_Odd} */ -const odd = () => ['const', { value: number, next: option(even) }] +const odd = () => ['const', { value: number, next: or(option, even) }] /** @typedef {() => readonly ['array', _Rec]} _Rec */ /** Every call returns a fresh recursive thunk whose function name is `f`. */ @@ -129,6 +129,45 @@ const closedChildren = () => ['array', closedNode] /** @type {_NestedRest} */ const nestedRest = () => ['rest', { a: number }, nestedRest] +/** + * A recursive rule that admits absence, with a non-empty present part — + * the referenced-rest exemption's ordinary case. + * + * @typedef {() => readonly ['or', typeof option, () => readonly ['array', _OptList]]} _OptList + */ + +/** @type {_OptList} */ +const optList = () => ['or', option, array(optList)] + +/** + * An absence-only cycle: the pure `or` cycle dissolves to the absent bit + * alone, so the rule's present part is empty — the case that shows masking + * a referenced rest would be unsound. + * + * @typedef {() => readonly ['or', typeof option, _AbsCycleB]} _AbsCycleA + * @typedef {() => readonly ['or', _AbsCycleA]} _AbsCycleB + */ + +/** @type {_AbsCycleA} */ +const absCycleA = () => ['or', option, absCycleB] + +/** @type {_AbsCycleB} */ +const absCycleB = () => ['or', absCycleA] + +/** + * A pure `or` cycle normalizing to `or(option, number)` — a *referenced* + * node whose stripped set equals a rest it trails. + * + * @typedef {() => readonly ['or', typeof option, typeof number, _OptNumB]} _OptNumA + * @typedef {() => readonly ['or', _OptNumA]} _OptNumB + */ + +/** @type {_OptNumA} */ +const optNumA = () => ['or', option, number, optNumB] + +/** @type {_OptNumB} */ +const optNumB = () => ['or', optNumA] + const tupleNumber = /** @type {const} */ ([number]) const tupleString = /** @type {const} */ ([string]) const tupleNumberNumber = /** @type {const} */ ([number, number]) @@ -207,18 +246,26 @@ export const proof = { // the `unknown` one, which the two kinds spell differently: a // `rest: unknown` past a tuple's prefix, and no `rest` at all on a // struct. An open tuple declaring nothing is therefore every array - // — and so is one whose every position restates that `rest`, which - // is trimmed away so that one set keeps one spelling + // — and so is one whose every position restates that `rest` as the + // declared-member top `or(option, unknown)`, which is trimmed away + // so that one set keeps one spelling. A position declared plain + // `unknown` is *not* that top — it must be present — so it stays. assertData(toData(open(emptyTuple)))([{}, { array: true }]) - assertData(toData(open(/** @type {const} */ ([unknownRtti]))))([{}, { array: true }]) - assertData(toData(open(/** @type {const} */ ([unknownRtti, unknownRtti]))))([{}, { array: true }]) - assertData(toData(open(/** @type {const} */ ([number, unknownRtti]))))(toData(open(tupleNumber))) + assertData(toData(open(/** @type {const} */ ([or(option, unknownRtti)]))))([{}, { array: true }]) + assertData(toData(open(/** @type {const} */ ([or(option, unknownRtti), or(option, unknownRtti)]))))([{}, { array: true }]) + assertData(toData(open(/** @type {const} */ ([unknownRtti]))))( + [{}, { array: [{ prefix: [unknown], rest: unknown }] }]) + assertData(toData(open(/** @type {const} */ ([number, or(option, unknownRtti)]))))(toData(open(tupleNumber))) assertData(toData(open(/** @type {const} */ ([number, 42]))))( [{}, { array: [{ prefix: [{ number: true }, { number: [42] }], rest: unknown }] }]) assertData(toData(open({})))([{}, { object: true }]) assertData(toData(open({ b: string, a: number })))( [{}, { object: [{ props: { a: { number: true }, b: { string: true } } }] }]) - assertData(toData(open({ a: unknownRtti })))([{}, { object: true }]) + // a key declared `unknown` must be *present*, so it survives even + // under `open` — the droppable declared top is `or(option, unknown)` + assertData(toData(open({ a: unknownRtti })))( + [{}, { object: [{ props: { a: unknown } }] }]) + assertData(toData(open({ a: or(option, unknownRtti) })))([{}, { object: true }]) assertData(toData(/** @type {const} */ ([neverRtti])))([{}, never]) assertData(toData({ a: neverRtti }))([{}, never]) }, @@ -247,10 +294,15 @@ export const proof = { // a `never` member empties the whole pattern, whatever the rest assertData(toData(/** @type {const} */ ([neverRtti])))([{}, never]) assertData(toData({ a: neverRtti }))([{}, never]) - // an unconstrained key is dropped only once the rest is gone: with - // one present, "anything at `a`" says strictly more than leaving - // `a` out, which `open({ a: unknown })` alone does not - assertData(toData(open({ a: unknownRtti })))([{}, { object: true }]) + // an unconstrained key — "anything, or nothing", the declared-member + // top `or(option, unknown)` — is dropped only once the rest is gone: + // with one present, "anything at `a`" says strictly more than + // leaving `a` out, which `open({ a: or(option, unknown) })` alone + // does not. Plain `unknown` at a key is not that top: it requires + // presence, so it is never dropped. + assertData(toData(open({ a: or(option, unknownRtti) })))([{}, { object: true }]) + assertData(toData({ a: or(option, unknownRtti) }))( + [{}, { object: [{ props: { a: { ...unknown, unit: unitBit(null) | unitBit(undefined) | unitBit(false) | unitBit(true) | absentBit } }, rest: never }] }]) assertData(toData({ a: unknownRtti }))( [{}, { object: [{ props: { a: unknown }, rest: never }] }]) // the same container bare and opened are two sets, so the @@ -308,12 +360,76 @@ export const proof = { assertData(toData(or(0, -0)))([{}, { number: [-0, 0] }]) assertData(toData(or('b', 'a')))([{}, { string: ['a', 'b'] }]) assertData(toData(or(2n, 1n)))([{}, { bigint: [1n, 2n] }]) - assertData(toData(option(string)))([{}, { unit: unitBit(undefined), string: true }]) + // absence is the fifth unit bit, merged like any other — and the + // explicit `thunkUnion` case is what keeps `toData(option)` from + // falling into the empty-operand `or` arm and reading as `never` + assertData(toData(option))([{}, { unit: absentBit }]) + assertData(toData(or(option, string)))([{}, { unit: absentBit, string: true }]) + assertData(toData(or(option, number)))([{}, { unit: absentBit, number: true }]) + assertData(toData(or(option, string, undefined)))( + [{}, { unit: unitBit(undefined) | absentBit, string: true }]) + assert(!equal(toData(or(option, number)))(toData(number))) assertData(toData(or(unknownRtti, number)))([{}, unknown]) assertData(toData(or(number, or(string, boolean))))( [{}, { unit: unitBit(false) | unitBit(true), number: true, string: true }]) assertData(toData(or(1, or(1, 2))))([{}, { number: [1, 2] }]) }, + // The normalizations the absent bit changes, pinned so each + // degenerate spelling's normal form stays deliberate. + absence: { + // a rest never sees an absent member, so an inline rest is + // stripped of the bit — on both kinds, and at the top-level + // spelling too + restIsStripped: () => { + assertData(toData(array(or(option, number))))(toData(array(number))) + assertData(toData(record(or(option, number))))(toData(record(number))) + assertData(toData(rest([number], or(option, string))))( + toData(rest([number], string))) + assertData(toData(rest({ a: number }, or(option, string))))( + toData(rest({ a: number }, string))) + // …while a declared *position* keeps its bit: absence is + // observable there + assertData(toData(open([or(option, number)])))( + [{}, { array: [{ prefix: [{ unit: absentBit, number: true }], rest: unknown }] }]) + }, + // `array(option)` has an empty element set once the bit is + // stripped, and a `never` rest is the exact-length set of its + // (empty) prefix: the empty array + arrayOfOptionIsTheEmptyArray: () => { + assertData(toData(array(option)))(toData(/** @type {const} */ ([]))) + assertEq(validate(toData(array(option)))([])[0], 'ok') + assertEq(validate(toData(array(option)))(new Array(1))[0], 'error') + }, + // the redesigned trim: a trailing declared position that admits + // absence and whose stripped set restates the rest is dropped — + // `rest([or(option, number)], number)` and `array(number)` denote + // one set of arrays, so they get one `Node` + trailingPositionRestatingTheRest: () => { + assertData(toData(rest([or(option, number)], number)))(toData(array(number))) + assertData(toData(rest([number, or(option, number)], number)))( + toData(rest([number], number))) + // without the bit the position is "one or more", not restating + assert(!equal(toData(rest([number], number)))(toData(array(number)))) + // and a stripped set differing from the rest is kept + assert(!equal(toData(rest([or(option, string)], number)))(toData(array(number)))) + }, + // `[option]` is not `[]`: its sole position strips to `never` + // like its (empty) rest, and the trim never reaches an empty + // rest — the two differ on `new Array(1)`, a length the first + // admits and the second bounds out + absentOnlyPositionIsNotDropped: () => { + assert(!equal(toData(/** @type {const} */ ([option])))( + toData(/** @type {const} */ ([])))) + const v1 = validate(toData(/** @type {const} */ ([option]))) + assertEq(v1(new Array(1))[0], 'ok') + assertEq(v1([])[0], 'ok') + assertEq(v1([1])[0], 'error') + assertEq(v1([undefined])[0], 'error') + const v0 = validate(toData(/** @type {const} */ ([]))) + assertEq(v0([])[0], 'ok') + assertEq(v0(new Array(1))[0], 'error') + }, + }, orCanonicalIdentity: () => { assertData(toData(or(number, string)))(toData(or(string, number))) const a = array(number) @@ -575,6 +691,80 @@ export const proof = { const oneNumberThenStrings = [{}, { array: [{ prefix: [{ number: true }], rest: { string: true } }] }] assert(!subset(oneOrMoreNumbers)(oneNumberThenStrings)) }, + // A **referenced** rest is left unstripped — the same rule may sit at + // a declared position, where the bit is live — and `subset` resolves + // it rather than masking the bit. The cost is one-way inclusion: the + // stripped form bounds nothing new where the present part is + // non-empty, and bounds the *length* where it is empty, so `equal` + // answers "different spelling" — the structural incompleteness + // `./README.md` records beside rule names. + referencedRest: () => { + // the exemption itself: the rest stays a reference, bit intact + assertData(toData(rest([number], optList)))([ + { optList: { unit: absentBit, array: [{ prefix: [], rest: 'optList' }] } }, + { array: [{ prefix: [{ number: true }], rest: 'optList' }] }, + ]) + // the stripped fixpoint is a *different rule*, not a bit-mask: + // one-way inclusion, resolved coinductively + /** @type {Data} */ + const stripped = [ + { optList0: { array: [{ prefix: [], rest: 'optList0' }] } }, + { array: [{ prefix: [{ number: true }], rest: 'optList0' }] }, + ] + assert(subset(stripped)(toData(rest([number], optList)))) + assert(!subset(toData(rest([number], optList)))(stripped)) + // the absence-only cycle is where masking would be unsound: the + // syntactic form keeps a rest and admits any hole-only array, + // while the stripped form is `never` and bounds the length — two + // sets, not one set spelled twice + const holey = toData(rest([number], absCycleA)) + assertData(holey)([ + { absCycleA: { unit: absentBit } }, + { array: [{ prefix: [{ number: true }], rest: 'absCycleA' }] }, + ]) + assertEq(validate(holey)([1, , , ])[0], 'ok') + assertEq(validate(holey)([1, 2])[0], 'error') + assertEq(validate(toData(tupleNumber))([1, , , ])[0], 'error') + assert(subset(toData(tupleNumber))(holey)) + assert(!subset(holey)(toData(tupleNumber))) + // a referenced **trailing position** is exempt by the same rule: + // `optNumA` normalizes to `or(option, number)`, whose stripped + // set restates the rest — an inline spelling trims — but neither + // `trimPrefix` nor `arraySet` takes a rule set to resolve the + // reference with, so it stays untrimmed, structurally distinct + // from `array(number)`, and still read the same way + const referencedTrailing = toData(rest([optNumA], number)) + assertData(referencedTrailing)([ + { optNumA: { unit: absentBit, number: true } }, + { array: [{ prefix: ['optNumA'], rest: { number: true } }] }, + ]) + assert(!equal(referencedTrailing)(toData(array(number)))) + // the readers still agree on what both spellings accept + assertEq(validate(referencedTrailing)([])[0], 'ok') + assertEq(validate(referencedTrailing)([1, 2])[0], 'ok') + assertEq(validate(referencedTrailing)(['x'])[0], 'error') + }, + // A declared position asks the object kind's two questions: the + // absence-stripped sets compared, and absence implied. This pair is + // what tells the two halves apart — the left's only values are + // `new Array(1)` and `[number]`, which `array(number)` admits (a hole + // is no entry) and the closed `[number]` does not (position 0 is + // required there). + arrayAbsence: () => { + assert(subset(toData(/** @type {const} */ ([or(option, number)])))(toData(array(number)))) + assert(!subset(toData(/** @type {const} */ ([or(option, number)])))(toData(tupleNumber))) + // absence implied by a hole past the right's prefix… + assert(subset(toData(/** @type {const} */ ([number, or(option, number)])))( + toData(rest([number], number)))) + // …or by the right position's own bit + assert(subset(toData(/** @type {const} */ ([or(option, 42)])))( + toData(/** @type {const} */ ([or(option, number)])))) + // and never invented: a stripped-equal pair still fails when the + // right requires presence + assert(!subset(toData(open([or(option, number)])))(toData(open(tupleNumber)))) + // the reverse inclusion is the ordinary pointwise one + assert(subset(toData(tupleNumber))(toData(/** @type {const} */ ([or(option, number)])))) + }, // The closed default makes a `rest`-less array pattern and an object // pattern with an empty `rest` the *ordinary* output of the thunk // form, where hand-written data used to be the only way to reach @@ -603,31 +793,33 @@ export const proof = { assert(subset(toData(closedNode))(toData(closedNode))) }, // A key present holding `undefined` and a key absent are two different - // objects, and the two sides of a pattern read them differently: a - // declared key constrains the value *read* at it, so absence passes - // when its set holds `undefined`; an undeclared key is checked as an - // *entry*, so a present `undefined` must belong to `rest` itself. - // - // One "read set" of `rest ∪ undefined` folded the two together, which - // stayed sound only while a struct's `rest` was `unknown` and failed - // the trailing rest check. A bare struct now supplies `never` there, - // so the fold is reachable from every schema and answered `true` for a - // non-inclusion. + // objects, told apart by two different bits: `unitBit(undefined)` is a + // value the key may hold, `absentBit` is leave-it-out. The per-key + // check asks two questions — the **absence-stripped** present sets + // compared, and absence implied — and neither implies the other. presenceIsNotAbsence: () => { - const p = toData({ a: option(number) }) + // `{ a: or(option, number) }` denotes `{}` and `{ a: number }`, + // both of which `record(number)` admits, so the inclusion holds — + // it is the *stripped* present set that is compared. The old + // `option(number)` spelling admitted `{ a: undefined }` and was + // rightly excluded; that spelling is now `or(option, number, + // undefined)`, and still is. + const p = toData({ a: or(option, number) }) const q = toData(record(number)) - assert(!subset(p)(q)) - // the witness, and the acceptance that makes it one - assertEq(validate(p)({ a: undefined })[0], 'ok') + assert(subset(p)(q)) + assertEq(validate(p)({ a: undefined })[0], 'error') assertEq(validate(q)({ a: undefined })[0], 'error') + assert(!subset(toData({ a: or(option, number, undefined) }))(q)) + assertEq(validate(toData({ a: or(option, number, undefined) }))({ a: undefined })[0], 'ok') // both halves of the per-key check are load-bearing, and neither // implies the other: this pair agrees on every present value and // differs only on whether the key may be missing assert(!subset(toData(record(number)))(toData(rest({ a: number }, number)))) assertEq(validate(toData(record(number)))({})[0], 'ok') assertEq(validate(toData(rest({ a: number }, number)))({})[0], 'error') - // and the open-struct spelling, which was sound before, still is - assert(!subset(toData({ a: option(number) }))(toData(record(number)))) + // present-undefined alone also breaks the inclusion — the absent + // bit is not what carries it + assert(!subset(toData({ a: or(number, undefined) }))(toData(record(number)))) }, objects: () => { assert(subset(toData(open({ a: number })))(toData(open({})))) @@ -638,7 +830,7 @@ export const proof = { assert(!subset(toData(record(or(number, string))))(toData(record(number)))) // a record's keys may be absent, a required key excludes that assert(!subset(toData(record(number)))(toData(open({ a: number })))) - assert(subset(toData(record(number)))(toData(open({ a: option(number) })))) + assert(subset(toData(record(number)))(toData(open({ a: or(option, number) })))) // an open struct leaves undeclared keys unconstrained, a record // does not — while a closed one names them all, so it is included assert(!subset(toData(open({ a: number })))(toData(record(number)))) @@ -726,7 +918,7 @@ export const proof = { assertEq( JSON.stringify(vt([1])), '["error",{"path":["1"],"message":"unexpected value"}]') - const vo = validate(toData(open(/** @type {const} */ ([number, option(string)])))) + const vo = validate(toData(open(/** @type {const} */ ([number, or(option, string)])))) assertEq(vo([1])[0], 'ok') assertEq(vo([1, 'a'])[0], 'ok') assertEq(vo([])[0], 'error') @@ -769,7 +961,7 @@ export const proof = { '["error",{"path":[],"message":"no match"}]') }, objects: () => { - const v = validate(toData(open({ a: number, b: option(string) }))) + const v = validate(toData(open({ a: number, b: or(option, string) }))) assertEq(v({ a: 1 })[0], 'ok') assertEq(v({ a: 1, b: 's' })[0], 'ok') assertEq(v({ a: 1, extra: true })[0], 'ok') diff --git a/fjs/rtti/data/types.ts b/fjs/rtti/data/types.ts index 667aa936e..70239abcd 100644 --- a/fjs/rtti/data/types.ts +++ b/fjs/rtti/data/types.ts @@ -23,9 +23,11 @@ export type KindSet = true | readonly T[] * A set of arrays: a tuple with an optional rest. * * - `prefix` constrains, per leading position, the value *read* at that - * position — a position past the array's end reads as `undefined`, so a - * position is required exactly when its set excludes `undefined`. This is - * the array half of the rule {@link ObjectSet} states for keys. + * position — and whether there needs to be one: a position past the + * array's end, or a hole, is **absent**, and a position is required + * exactly when its set excludes absence (the `absentBit` of its `unit` + * bitset). This is the array half of the rule {@link ObjectSet} states + * for keys. * - `rest` present: the value at every position past the prefix belongs to * `rest`. * - `rest` absent: there is nothing past the prefix. @@ -46,10 +48,12 @@ export type ArraySet = { /** * A set of objects: per-key value sets with an optional rest. * - * - `props` constrains, per declared key, the value *read* at that key — an - * absent property reads as `undefined`, so a key is required exactly when - * its set excludes `undefined`. Keys are canonically sorted, and a key - * whose set is the whole value domain is omitted. + * - `props` constrains, per declared key, the value *read* at that key — and + * whether there needs to be one: a key is required exactly when its set + * excludes **absence** (the `absentBit` of its `unit` bitset), so `{}` and + * `{ a: undefined }` are told apart. Keys are canonically sorted, and a + * key whose set is the whole *declared-member* domain — any value, or + * nothing — is omitted. * - `rest` present: the value at every other *present* key belongs to `rest`. * - `rest` absent: other keys are unconstrained. * @@ -67,10 +71,15 @@ export type ObjectSet = { * with every component at its maximum (see `unknown` in `./module.f.mjs`) * is `unknown`. * - * `unit` is a bitset over the four singleton values; bit `1 << i` stands for - * `unitList[i]` from `./module.f.mjs` (`['null', 'undefined', 'false', - * 'true']`), so `or(true, false)` collapses to the two boolean bits with no - * special-case rule. + * `unit` is a bitset over the four singleton values plus **absence**; bit + * `1 << i` for `i < 4` stands for `unitList[i]` from `./module.f.mjs` + * (`['null', 'undefined', 'false', 'true']`), so `or(true, false)` collapses + * to the two boolean bits with no special-case rule. Bit `16` is + * `absentBit`, rtti's nullary `option`: the member that is not there. It + * maps to no `unitList` entry because absence is not a DJS value — nothing + * reads as absent; a container *position* is absent by having no own or + * inherited key — so a consumer decoding stored data must treat bit `16` as + * the may-be-omitted marker of a declared member, not as a fifth value. */ export type UnionSet = { readonly unit?: number diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index ab24b36bb..ef9d538d5 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -25,7 +25,7 @@ import { assert, assertEq, assertStructurallySame } from '../asserts/module.f.mjs' import { undeclaredMembers } from './common/module.f.mjs' import { toData, validate as dataValidate } from './data/module.f.mjs' -import { array, number, rest, string } from './module.f.mjs' +import { array, number, option, or, rest, string } from './module.f.mjs' import { parse } from './parse/module.f.mjs' import { validate } from './validate/module.f.mjs' @@ -167,4 +167,40 @@ export const proof = { assertOk(read(rest([number], number))(value)) } }, + // A declared member absent by own-key but supplied by the **prototype** is + // present to the readers — HasProperty, the same test `getItem`'s read + // answers to — so the inherited value must satisfy the member's present + // part: `or(option, t)`'s `option` branch rejects any present value, so + // dispatching the read value *is* the present-part check. Without it, + // `validate` would hand back an object whose `.a` reads `'bad'` while the + // rendered type promises `number` — the own-key rule alone would have + // introduced that unsoundness, not inherited it. + inheritedDeclaredMemberMeetsThePresentPart: () => { + const value = Object.create({ a: 'bad' }) + for (const read of [v, p, d]) { + assertError(read({ a: or(option, number) })(value)) + assertOk(read({ a: or(option, string) })(value)) + } + }, + // …and `parse` **materializes** the inherited value as an own member of + // what it builds: against a prototype-supplied index no immutable builder + // can produce the hole — `slice` and `.map` copy by HasProperty, an + // `Object.hasOwn` guard inside a `.map` callback cannot stop the own + // output element from existing, and a fresh `Array(n)` inherits the index + // too — so this is a pinned, bounded divergence, unreachable from + // FunctionalScript (which has neither mutation nor prototype writes). + // `validate` is untouched: it returns the value it was given. + parseMaterializesAnInheritedIndex: () => { + const value = inheritedIndex() + const schema = /** @type {const} */ ([number, or(option, number)]) + const r = p(schema)(value) + assert(r[0] === 'ok', 'expected ok') + const built = /** @type {ReadonlyArray} */ (r[1]) + assert(Object.hasOwn(built, 1), 'the inherited index is an own member of the result') + assertEq(built[1], 99, 'carrying its parsed value') + const rv = v(schema)(value) + assert(rv[0] === 'ok', 'expected ok') + assert(Object.is(rv[1], value), '`validate` hands back the value it was given') + assert(!Object.hasOwn(/** @type {object} */ (rv[1]), 1), 'holes and all') + }, } diff --git a/fjs/rtti/module.f.mjs b/fjs/rtti/module.f.mjs index b9960f16e..500aa3d18 100644 --- a/fjs/rtti/module.f.mjs +++ b/fjs/rtti/module.f.mjs @@ -7,7 +7,7 @@ * @import { Includes } from '../types/array/types.ts' * @import { Assert } from '../asserts/types.ts' * @import { Equal } from '../types/ts/types.ts' - * @import { Tag0, Primitive0, _Type0, Bigint, Unknown, Tag1, _MakeType1, _MakeOpen, _MakeRest, Or, Type } from './types.ts' + * @import { Tag0, Primitive0, _Type0, Bigint, Unknown, Option, Tag1, _MakeType1, _MakeOpen, _MakeRest, Or, Type } from './types.ts' */ import { includes } from '../types/array/module.f.mjs' @@ -16,7 +16,7 @@ const primitive0List = /** @type {const} */ (['bigint', 'boolean', 'number', 'st /** @typedef {Assert>} _Primitive0Pinned */ -export const tag0List = /** @type {const} */ ([...primitive0List, 'unknown']) +export const tag0List = /** @type {const} */ ([...primitive0List, 'unknown', 'option']) const type0 = /** @@ -96,14 +96,27 @@ export const or = (...types) => () => ['or', ...types] /** - * Constructs a schema that validates a value matching `T` or `undefined`. + * Schema denoting **absence** — the member that is not there. A nullary + * schema like {@link boolean} or {@link unknown}: it takes no argument and + * wraps nothing; a member that may be omitted says so by union. * - * @template {Type} const T - * @param {T} t - * @returns {Or} + * ```js + * { a: or(option, number) } // `a` may be absent, or a number + * { a: or(number, undefined) } // `a` must be present, may hold `undefined` + * { a: or(option, number, undefined) } // absent, a number, or a present `undefined` + * [or(option, number), 3] // position 0 may be a hole + * ``` + * + * Absence is not a spelling of the value `undefined`: `{}` and + * `{ a: undefined }` are two distinct values, and only a set admitting + * absence accepts the first. It is observable only at a container position — + * no caller can hand a reader an argument that is not there, so a top-level + * schema admitting absence accepts exactly what the rest of its union + * accepts — and a container's `rest` never sees it, a hole being no member. + * + * @type {Option} */ -export const option = t => - or(t, undefined) +export const option = type0('option') /** * Schema that never matches any value — the empty union, corresponding to TypeScript's `never`. diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 3baa2e18f..c16e22e9b 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -15,10 +15,12 @@ * always returned even if the inner type is a primitive. * * Closedness is about *undeclared* members, and leaves the required/optional - * rule alone: a member is required exactly when its set excludes `undefined` — - * an absent member reads as `undefined`, on both kinds — so a shorter array - * whose trailing position admits `undefined` is accepted and the gap is - * filled. + * rule alone: a member is required exactly when its set excludes **absence** + * — the `option` bit of its union — so a shorter array whose trailing + * position says `or(option, t)` is accepted. An absent member is omitted + * from what is built, never materialized as `undefined`: the struct kind + * drops the key, the array kind keeps a hole a hole and shortens a trailing + * absent run. * * A tuple schema declares by length, so a hole in the *schema* is a declared * position whose schema is `undefined` — see "A hole is a declared position" @@ -57,6 +59,7 @@ import { ok } from '../../types/result/module.f.mjs' import { reverse, toArray } from '../../types/list/module.f.mjs' import { + absentMember, constPrimitiveValidate, eachEntry, isArray, @@ -82,6 +85,46 @@ const arrayRebuild = entries => entries.map(([, v]) => v) /** @type {_Rebuild} */ const recordRebuild = entries => Object.fromEntries(entries) +/** + * Rebuilds a **const** container from the declared members that were + * present, keyed by the same HasProperty test the check dispatched on — + * one per kind, since only the array kind has holes to preserve. + * + * @template C + * @typedef {(value: C, entries: ReadonlyArray) => Unknown} _RebuildDeclared + */ + +/** + * The array kind's rebuild is **slice, then map**: truncate the value to the + * last *present* declared position, then map each present index to its + * parsed result. Mapping alone is not enough — `.map` preserves length, so a + * trailing absent run would survive as a sparse tail and serialize back to + * the `null`s this stage removes — and omitting absent entries from a + * rebuilt list would shift every position after an interior hole. + * `slice` and `.map` both skip a hole, so an interior one survives as a + * hole; both use HasProperty, so an index the value only *inherits* is + * materialized as an own property of the result, carrying its parsed value. + * That divergence is bounded and pinned rather than closed: no immutable + * builder can produce the hole against a prototype-supplied index — + * `.map` creates the own output element whatever the callback returns, and + * a fresh `Array(n)` inherits the index too — and the escapes + * (`Object.assign`, index assignment) are mutation, which FunctionalScript + * forbids. See `../host.proof.mjs`. + * + * @type {_RebuildDeclared>} + */ +const tupleRebuild = (value, entries) => { + if (entries.length === 0) { return [] } + /** @type {StringMap} */ + const byIndex = Object.fromEntries(entries) + const end = Number(entries[entries.length - 1][0]) + 1 + return value.slice(0, end).map((_, i) => byIndex[i]) +} + +/** The struct kind drops an absent key: only the present entries are rebuilt. */ +/** @type {_RebuildDeclared>} */ +const structRebuild = (_value, entries) => Object.fromEntries(entries) + /** `eachEntry`'s accumulator seed: entries are consed on in reverse as they parse. */ /** @type {List} */ const emptyEntries = null @@ -91,6 +134,18 @@ const emptyEntries = null const consEntry = (acc, k, v) => ({ first: [k, v], tail: acc }) +/** + * `eachEntry`'s accumulate step over *declared* members, whose item wraps a + * present member's parsed value in a one-element list and an absent member + * in an empty one: the present value is kept, the absent member leaves no + * entry. The wrapping is what stands in for a sentinel — every value, + * `undefined` included, is a legal parse result, so no value could mark + * absence. + */ +/** @type {(acc: List, k: string, vs: ReadonlyArray) => List} */ +const consPresent = (acc, k, vs) => + vs.length === 0 ? acc : ({ first: [k, vs[0]], tail: acc }) + /** A uniform container declares no member by name, so every one is undeclared. */ /** @type {readonly string[]} */ const noDeclared = [] @@ -166,6 +221,16 @@ const noAccumulate = () => undefined * a member on both, but an array is also *as long as it is*: a hole past the * prefix is no member and would slip through the member check alone, so the * array kind answers with its length as well. + * + * A declared member is **absent** when its key or index is neither an own + * property nor an inherited one — the same HasProperty test + * `../validate/module.f.mjs` dispatches on — and absence is decided here, + * before dispatch, since the recursive reader is handed only the value read. + * An absent member is legal exactly when its schema admits absence, and is + * **omitted** from what is built rather than materialized as `undefined`: + * the struct kind drops the key, and the array kind preserves indices — + * a trailing absent run shortens the result, an interior one stays a hole + * (see `tupleRebuild`). */ const constContainerParse = /** @@ -174,7 +239,7 @@ const constContainerParse = * @param {IsContainer} isContainer * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem - * @param {_Rebuild} rebuild + * @param {_RebuildDeclared} rebuild * @param {Fits} fits * @returns {(rtti: T) => Parse} */ @@ -189,13 +254,20 @@ const constContainerParse = } const r = eachEntry( rttiEntries, - (k, t) => (/** @type {any} */ (parse(t))(getItem(value, k))), + (k, t) => { + if (!(k in value)) { + const a = absentMember(t) + return a[0] === 'error' ? a : ok([]) + } + const p = /** @type {any} */ (parse(t))(getItem(value, k)) + return p[0] === 'error' ? p : ok([p[1]]) + }, emptyEntries, - consEntry, + consPresent, ) if (r[0] === 'error') { return r } return undeclaredMembers(declared, value).length === 0 && fits(value, declared.length) - ? /** @type {any} */ (ok(rebuild(orderedEntries(r[1])))) + ? /** @type {any} */ (ok(rebuild(value, orderedEntries(r[1])))) : verror('unexpected value') } } @@ -204,7 +276,7 @@ const tupleParse = constContainerParse( isArray, tupleSchemaEntries, (value, k) => value[Number(k)], - arrayRebuild, + tupleRebuild, (value, declared) => value.length <= declared, ) @@ -212,7 +284,7 @@ const structParse = constContainerParse( isObject, structSchemaEntries, (value, k) => value[k], - recordRebuild, + structRebuild, () => true, ) @@ -234,7 +306,7 @@ const restContainerParse = * @param {IsContainer} isContainer * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem - * @param {_Rebuild} rebuild + * @param {_RebuildDeclared} rebuild * @param {(rtti: S, r: Type) => Fits} restFits * @returns {(rtti: S, r: Type) => ValidateE} */ @@ -250,20 +322,27 @@ const restContainerParse = } const d = eachEntry( rttiEntries, - (k, t) => (/** @type {any} */ (parse(t))(getItem(value, k))), + (k, t) => { + if (!(k in value)) { + const a = absentMember(t) + return a[0] === 'error' ? a : ok([]) + } + const p = /** @type {any} */ (parse(t))(getItem(value, k)) + return p[0] === 'error' ? p : ok([p[1]]) + }, emptyEntries, - consEntry, + consPresent, ) if (d[0] === 'error') { return d } const extra = undeclaredMembers(declared, value) if (extra.length === 0) { return fits(value, declared.length) - ? ok(rebuild(orderedEntries(d[1]))) + ? ok(rebuild(value, orderedEntries(d[1]))) : verror('unexpected value') } const restParse = /** @type {any} */ (parse(r)) const e = eachEntry(extra, (_k, v) => restParse(v), undefined, noAccumulate) - return e[0] === 'error' ? e : ok(rebuild(orderedEntries(d[1]))) + return e[0] === 'error' ? e : ok(rebuild(value, orderedEntries(d[1]))) } } @@ -271,7 +350,7 @@ const restTupleParse = restContainerParse( isArray, tupleSchemaEntries, (value, k) => value[Number(k)], - arrayRebuild, + tupleRebuild, (rtti, r) => (value, declared) => value.length <= declared || !emptyRest(rtti, r), ) @@ -279,7 +358,7 @@ const restStructParse = restContainerParse( isObject, structSchemaEntries, (value, k) => value[k], - recordRebuild, + structRebuild, () => () => true, ) @@ -338,6 +417,11 @@ const parseVisitor = /** @type {any} */ ({ constPrimitive: constPrimitiveValidate, primitive0: primitive0Validate, unknown: () => ok, + // Absence is decided by the container loop before dispatch, so a value + // that reaches this handler is present — and no present value is absent. + // An ordinary error is what lets `orVisit` try the other members of + // `or(option, t)`. + option: () => () => verror('unexpected value'), }) /** @type {(rtti: T) => Parse} */ diff --git a/fjs/rtti/parse/proof.f.mjs b/fjs/rtti/parse/proof.f.mjs index 18e9b6325..b325d6f29 100644 --- a/fjs/rtti/parse/proof.f.mjs +++ b/fjs/rtti/parse/proof.f.mjs @@ -33,9 +33,9 @@ const unwrap = r => { } /** A container that contains itself: `[number, node?]`. */ -/** @typedef {readonly [number, _Node | undefined]} _Node */ +/** @typedef {readonly [number, _Node?]} _Node */ -const _node = () => /** @type {const} */ (['const', [number, option(_node)]]) +const _node = () => /** @type {const} */ (['const', [number, or(option, _node)]]) /** @type {Phantom} */ const node = _node @@ -182,12 +182,13 @@ export const proof = { assertStructurallySame(unwrap(parse(open([42]))([42, 'extra'])), [42]) assertStructurallySame(unwrap(parse(open([42]))([42, 1, 2, 3])), [42]) }, - // An absent member reads as `undefined`, so a position is required - // exactly when its set excludes `undefined` — the same rule the - // data form states for object keys, applied to arrays. - shortArrayFillsAnOptionalPosition: () => { - const r = parse([number, option(string)])([42]) - assertStructurallySame(unwrap(r), [42, undefined]) + // A position is required exactly when its set excludes absence — + // the same rule the data form states for object keys, applied to + // arrays — and an absent trailing position stays absent: `parse` + // shortens the result rather than materializing `undefined`. + shortArrayLeavesAnOptionalPositionOut: () => { + const r = parse([number, or(option, string)])([42]) + assertStructurallySame(unwrap(r), [42]) }, error: () => { assertError(parse([42])([99])) @@ -316,15 +317,45 @@ export const proof = { }, }, option: { + // At the entry position nothing can be absent — see the same block in + // `../validate/proof.f.mjs`. ok: () => { - const t = option(number) + const t = or(option, number) assertOk(parse(t)(42)) - assertOk(parse(t)(undefined)) + assertOk(parse(or(option, number, undefined))(undefined)) }, error: () => { - const t = option(number) + const t = or(option, number) + assertError(parse(t)(undefined)) assertError(parse(t)(null)) assertError(parse(t)('42')) + assertError(parse(option)(undefined)) + }, + }, + // What `parse` builds around an absent member, asserted on the value + // rather than on acceptance alone: an interior absent position stays a + // hole — materializing `undefined` would denote a different value, and + // omitting it would shift everything after it — and a trailing absent + // run shortens the result. This is the JSON round-trip defect of the old + // design dissolved: no `undefined` is materialized, so nothing turns + // into `null` on the wire. + absentPositions: { + interiorHoleSurvives: () => { + /** @type {ReadonlyArray} */ + const built = unwrap(parse([or(option, number), 3])([, 3])) + assertEq(built.length, 2, 'the hole keeps its position') + assert(!Object.hasOwn(built, 0), 'no own index 0') + assertEq(built[1], 3, 'and `3` stays at index 1') + }, + trailingRunShortens: () => { + /** @type {ReadonlyArray} */ + const built = unwrap(parse([number, or(option, number), or(option, number)])([1, , ])) + assertEq(built.length, 1, 'the trailing absent run is gone') + assertEq(built[0], 1, 'the present prefix survives') + }, + structDropsTheKey: () => { + const built = unwrap(parse({ a: number, b: or(option, string) })({ a: 1 })) + assert(!('b' in built), 'an absent key is not materialized') }, }, path: { @@ -399,11 +430,11 @@ export const proof = { // Nor is a key that is no position at all. nonIndexKeyRejected: () => assertError(parse([number])(Object.assign([1], { foo: 2 }))), - // The rule for a missing member is unchanged: an absent position - // reads as `undefined`. + // A missing member is absent: required where its set excludes + // absence, omitted from what is built where it does not. shortArray: () => { assertError(parse([number])([])) - assertStructurallySame(unwrap(parse([number, option(string)])([1])), [1, undefined]) + assertStructurallySame(unwrap(parse([number, or(option, string)])([1])), [1]) }, empty: () => { assertStructurallySame(unwrap(parse([])([])), []) @@ -462,8 +493,8 @@ export const proof = { // `../ts/types.ts`); it is the *value* half under test here. recursive: () => { const p = parse(node) - assertStructurallySame(unwrap(p([1])), [1, undefined]) - assertStructurallySame(unwrap(p([1, [2]])), [1, [2, undefined]]) + assertStructurallySame(unwrap(p([1])), [1]) + assertStructurallySame(unwrap(p([1, [2]])), [1, [2]]) assertError(p([1, [2], 3])) }, // A cycle through the `rest` itself: every key other than `a` holds @@ -476,7 +507,7 @@ export const proof = { }, }, arrayOptional: () => { - const a = /** @type {const} */([number, option(string)]) + const a = /** @type {const} */([number, or(option, string)]) const v = parse(a) assertOk(v([5])) assertError(v(["n"])) diff --git a/fjs/rtti/proof.f.mjs b/fjs/rtti/proof.f.mjs index 3eeb00e36..de6bd698b 100644 --- a/fjs/rtti/proof.f.mjs +++ b/fjs/rtti/proof.f.mjs @@ -2,7 +2,7 @@ * @import { StringMap } from '../types/object/types.ts' * @import { Assert } from '../asserts/types.ts' * @import { Equal } from '../types/ts/types.ts' - * @import { Or, Rest, Type1, Unknown } from './types.ts' + * @import { Option, Or, Rest, Type1, Unknown } from './types.ts' */ import { assertNotNullish, assertStructurallySame } from '../asserts/module.f.mjs' @@ -21,8 +21,8 @@ const tests = { function: [() => undefined] } -// `or`, `option`, `array`, `record`, `rest` and `open` take `const` type -// parameters, so a literal written at the call site stays a literal: `or(42, string)` +// `or`, `array`, `record`, `rest` and `open` take `const` type parameters, so +// a literal written at the call site stays a literal: `or(42, string)` // describes `42 | string`, not `number | string`. Without the modifier a caller // has to pin every literal with an `@type {const}` cast, and the assertions // below are what fail if one of the modifiers is dropped. Each is paired with @@ -33,9 +33,13 @@ const constInference = () => { /** @typedef {Assert>>} _OrConst */ assertStructurallySame(orConst(), ['or', 42, string]) - const optionConst = option([42, string]) - /** @typedef {Assert>>} _OptionConst */ - assertStructurallySame(optionConst(), ['or', [42, string], undefined]) + // `option` is nullary — absence itself, not a wrapper — so the spelling + // under test is the union that carries it. + const optionUnion = or(option, [42, string]) + /** @typedef {Assert>} _OptionNullary */ + /** @typedef {Assert>>} _OptionUnion */ + assertStructurallySame(option(), ['option']) + assertStructurallySame(optionUnion(), ['or', option, [42, string]]) const arrayConst = array('hello') /** @typedef {Assert>>} _ArrayConst */ diff --git a/fjs/rtti/todo/checked-const-pin.md b/fjs/rtti/todo/checked-const-pin.md index aa5f92e58..32c8b358d 100644 --- a/fjs/rtti/todo/checked-const-pin.md +++ b/fjs/rtti/todo/checked-const-pin.md @@ -11,7 +11,7 @@ available is a cast: ```js export const casAddArgs = /** @type {const} */ ({ content: string, - type: or('text', 'base64', undefined) + type: or(option, 'text', 'base64') }) ``` @@ -33,7 +33,7 @@ A `const` type parameter would do both jobs at once: */ export const type = t => t -export const casAddArgs = type({ content: string, type: or('text', 'base64', undefined) }) +export const casAddArgs = type({ content: string, type: or(option, 'text', 'base64') }) ``` `type` pins exactly as `as const` does — that is what the modifier means — and diff --git a/fjs/rtti/todo/data-validate-admits-non-djs-values.md b/fjs/rtti/todo/data-validate-admits-non-djs-values.md index 8b5ff52e2..59daafe1f 100644 --- a/fjs/rtti/todo/data-validate-admits-non-djs-values.md +++ b/fjs/rtti/todo/data-validate-admits-non-djs-values.md @@ -36,7 +36,7 @@ Against `f = (a, b) => 1`: | `{}` | error | **ok** | `object` | | `record(number)` | error | **ok** | `object` | | `or(number, {})` | error | **ok** | `number,object` | -| `option({})` | error | **ok** | `unit,object` | +| `or(option, {}, undefined)` | error | **ok** | `unit,object` | | `{ length: number }` | error | **ok** | `object` | | `{ name: string }` | error | **ok** | `object` | | `{ length: number, name: string }` | error | **ok** | `object` | @@ -129,7 +129,7 @@ is an investigation, not a plan. [identity-aware-parse](identity-aware-parse.md) needs. - [ ] Whatever lands, make the three readers agree, and cover functions and symbols in tests against `unknown`, `{}`, `record(...)`, - `or(number, {})`, `option({})`, the required-property cases the intrinsics + `or(number, {})`, `or(option, {}, undefined)`, the required-property cases the intrinsics satisfy (`{ length: number }`, `{ name: string }`, `{ description: string }`), their near misses (`{ a: number }`, `{ length: string }`), and — if descent is adopted — nested and cyclic diff --git a/fjs/rtti/todo/option-as-omission.md b/fjs/rtti/todo/option-as-omission.md deleted file mode 100644 index 07bb44a07..000000000 --- a/fjs/rtti/todo/option-as-omission.md +++ /dev/null @@ -1,713 +0,0 @@ -# `option` as omission - -**Priority:** P2 -**Status:** open — stage 1 has landed; stage 2 is what is left - -Two stages, in this order: - -1. ~~a bare `Const` is **closed**; `open(c)` / `rest(c, r)` state otherwise~~ — - **landed.** `close` is gone, `rest(c, r)` and `open(c)` are the spellings, - the readers bound a tuple's length, and `RestTs` renders the tail. What that - stage decided is now stated in the code it changed — - [`../README.md`](../README.md) for the model, - [`emptyRest`](../data/module.f.mjs) for the empty-rest criterion, and - `undeclaredMembers` in [`../common/module.f.mjs`](../common/module.f.mjs) - for how a container's undeclared members are read — so this file no longer - restates it; -2. `option` becomes a **nullary schema denoting absence**, so a member that may - be omitted is `or(option, t)` rather than `or(t, undefined)`. - -One issue rather than two, because stage 2 reads the acceptance tables stage 1 -rewrote. In the other order every table, proof and consumer schema would have -been rewritten twice, and the intermediate state — omission already distinct -while a bare container was still open — had no consumer asking for it. - -## Problem - -### `option(t)` is `or(t, undefined)`, so absence is not describable - -`option` is not a concept today — `../module.f.mjs` defines it as -`or(t, undefined)`, and absence is read as the value `undefined`. Three -consequences: - -**A set the form cannot express.** `undefined` is a DJS value, so `{}` and -`{ a: undefined }` are two distinct DJS values. No schema separates them: a -declared key constrains the value *read* at it, an absent key reads `undefined`, -so every schema admitting one admits the other. For a module whose premise is -that a `Type` denotes a set of values, that is a completeness gap. - -**The rule is already not uniform.** [`../data/README.md`](../data/README.md) -states the asymmetry itself: a *declared* key is checked as a value read, but an -*undeclared* key is checked as an **entry** — `{ props: { a: number }, rest: string }` -rejects `{ a: 1, b: undefined }` and accepts `{ a: 1 }`. The form can already tell -present-`undefined` from absent; it just cannot do so at a declared position. -Stage 2 does not introduce the distinction, it finishes it. - -**Construction has no forced answer.** Given `{ a: number, b: option(string) }` -and `{ a: 1 }`, both `{ a: 1 }` and `{ a: 1, b: undefined }` are correct outputs -and `parse` picks one by fiat — with a real defect on the array kind, where the -pick does not survive JSON (`[42, undefined]` → `'[42,null]'` → rejected). That is -[parse-omits-undefined-members](./parse-omits-undefined-members.md), which stage 2 -dissolves rather than decides. - -TypeScript is on the other side of this already: this repo sets -`exactOptionalPropertyTypes: true` ([`../../../tsconfig.json`](../../../tsconfig.json)), -so `x?: string` and `x: string | undefined` are distinct there while RTTI conflates -them and renders the hybrid `{readonly "x"?: undefined|string}`. - -## Proposal - -### `option` is the absent value - -`option` is a nullary schema like `boolean` or `unknown` — `() => ['option']` — -denoting one thing: **the member that is not there**. It takes no argument and -wraps nothing; a member that may be omitted says so by union. - -```js -{ a: or(option, number) } // `a` may be absent, or a number -{ a: or(number, undefined) } // `a` must be present, may hold `undefined` -{ a: or(option, number, undefined) } // today's `option(number)` -[or(option, number), 3] // position 0 may be a hole -``` - -Absence stops being a spelling of `undefined` and becomes a value in its own -right. Everything else follows from the representation the data form already -has. - -**It costs one bit.** `unitList` in [`../data/module.f.mjs`](../data/module.f.mjs) -is a bitset over `null, undefined, false, true`; absence is a fifth member of -that kind — exactly as [`../data/README.md`](../data/README.md) describes -`or(true, false)` being the two boolean bits rather than a special rule. Union, -`subset`, `cmp`, `equal` and the coverage collapse are bitwise over that kind, so -**the set algebra does not change**. - -The *normalizations* do, and only one of the three is a straight substitution: - -| site | today | stage 2 | -| --- | --- | --- | -| `objectMayOmit` | a key is omittable when its set admits `undefined` | …when its set admits absence — a straight swap | -| `objectSet`'s `isTop` | a declared key whose set is `unknown` is dropped, and only once the `rest` is gone | the rest guard **stays**; `isTop` becomes position-aware — `unknown` for a `rest`, `or(option, unknown)` for a declared member | -| `trimPrefix` | a trailing position restating a `rest` that admits `undefined` is dropped | the rest no longer carries the bit, so the test moves to the trailing **declared position**: drop it when it admits absence and its absence-stripped set equals the rest | - -Neither of the last two can be reached by swapping the bit, and both would -mis-canonicalize if it were: - -- **`trimPrefix`.** Measured today, `rest([option(number)], option(number))` and - `array(option(number))` are the same `Node` — the rest admits `undefined`, so - the trim fires. Its counterpart here is `rest([or(option, number)], number)`: - position 0 may be absent and every present entry is a number, so it denotes the - same arrays as `array(number)`. But a `rest` carries no absent bit, so a bit - test on the rest is dead, the prefix survives, and two spellings of one set get - different `toData` — breaking `equal` and `cmp`. -- **The declared-key drop.** `{ a: or(option, unknown) }` is closed, so it - carries `rest: never` and denotes objects with at most the key `a`. - Dropping `a` would leave the empty object, a different set. `objectSet` already - guards the filter with `r === undefined` ("the rest is gone"); that guard stays - and only the predicate moves. - -That is the structural cost — one bit, one swap, two normalizations to redesign — -and it is still why this shape is preferred over the wrapper `option(t)`: a -wrapper is not a set of values, so it would have -needed a second syntactic category (`Member = Type | Option`, legal only -at a container position) and an `{ optional, node }` pair on every `props` entry -and `prefix` position, with every algebra function and its proof rewritten. - -#### The four rules the bit needs - -**`unknown` excludes it.** `unknown` is the set of DJS values and absence is not -one, so "anything, or nothing" is `or(option, unknown)`. That is the top of a -*declared member*, so the ordering caveat `../data/README.md` records — a -declared key whose set is the top is dropped only once the `rest` is gone — -**stays**, with `or(option, unknown)` as the top it tests. That guard is what -keeps `{ a: or(option, unknown) }` denoting objects with at most the key `a` -rather than the empty object. - -**It is observable only at a container position.** No caller can hand `validate` -an argument that is not there, so a top-level schema admitting absence accepts -exactly what the rest of its union accepts. Nothing has to enforce this: -`unionValidate` is only ever reached with a present value. - -**A `rest` never sees it.** A declared member is checked as the value *read* at -its position; a `rest` is checked against each *present* member. So the absent bit -in a `rest` constrains nothing and normalizes away on both kinds. This is not new -behaviour: `../parse` and `../validate` walk a value with `Object.entries`, which -skips holes, so `array(number)` accepts `[1, , 3]` today. - -**A referenced rest is left alone**, which is the one place the strip cannot be -applied. `trimPrefix` already declines to see through a reference ("reading its -unit bits would need the rule set"), and a rest that resolves to a rule cannot be -stripped in place: the same rule may be used at a declared position, where the -bit is meaningful, so clearing it globally would delete optionality elsewhere. -For `X = or(option, array(X))` used as a rest, the stripped form is not even -inline — it is the fixpoint `X' = array(X')`, a derived rule per rule reachable -at a rest position. - -So stage 2 strips an **inline** rest and leaves a **referenced** one as it is. -The cost is that `rest(c, X)` and `rest(c, X')` are then structurally distinct -while denoting one set, which is exactly the incompleteness -[`../data/README.md`](../data/README.md) already accepts and documents for rule -*names* — semantically equal, structurally distinct, and mutual `subset`s rather -than `equal`. `subset` gets there by **resolving** the rest rather than masking -the bit — it already resolves references coinductively — and comparing present -parts. - -Masking would be unsound, and the case that shows it is reachable: for -`X = or(option, Y)` with `Y = or(X)`, the pure `or` cycle dissolves to the absent -bit alone, so `X`'s present part is empty. Then `rest(c, X)` keeps a `rest` and -admits any hole-only array, while the stripped `X'` is `never`, which normalizes -to no `rest` and so **bounds the length** — the two denote different sets, not one -set spelled twice. A mask would report them as mutual subsets, and `subset` -answering `true` for a non-inclusion is the one thing `../data/README.md` -promises it never does. So the structural distinctness is an accepted -incompleteness where the present part is non-empty, and simply *correct* where it -is empty. - -Materializing derived rules instead would restore full canonicality at the price -of a fixpoint construction over the rule graph, a naming scheme that cannot -collide with user rule names, and memo identities for the derived names — the -bisimulation-grade direction `../data/README.md` deliberately avoids. Revisit -only if a consumer needs `equal` to see through it. - -**Length still bounds a closed array**, which settles the one case the strip -creates rather than leaving it to be discovered. `array(option)` has an empty -element set once the bit is stripped; a `never` rest normalizes to no rest, which -on the array kind is the exact-length set, so `array(option)` is the empty array -— not "hole-only arrays of any length". That is the reading a bare container -already has, and stage 1 made all three readers agree on it: `emptyRest` in -[`../data/module.f.mjs`](../data/module.f.mjs) decides when a stated rest makes -no difference to the canonical form, and the array-kind readers bound their -length by it. So the rows below hold today, `array(or())` included, and nothing -here has to re-establish them: - -| schema | value | thunk `validate` | data `validate` | `parse` | -| --- | --- | --- | --- | --- | -| `[]` | `new Array(1)` | error | error | error | -| `[1]` | `[1, ,]` | error | error | error | -| `array(or())` | `new Array(1)` | error | error | error | - -What this stage adds is one more spelling reaching the same bound: once the rest -is stripped of its absent bit, `array(option)` has an empty element set, so it -denotes the empty array. The criterion is already there to answer it. - -**Absence at a tuple position is "no such own index"** — past the end or a hole, -one rule for both. That makes the value side symmetric with the schema side -settled in #1712, where a hole in a *schema* is a declared `undefined` position. -Construction has to preserve it: an interior absent position must stay a hole, -because materializing it as `undefined` now denotes a different value, and -omitting it from a rebuilt list shifts every position after it. - -#### What it means for the two hard spellings - -Both cases that a wrapper design would have had to forbid are ordinary unions -here, with ordinary meanings: - -| schema | accepts | rejects | -| --- | --- | --- | -| `[or(option, number), 3]` | `[, 3]` | `[undefined, 3]`, `[3]` | -| `or(option, number, string)` at a key | `{}`, `{ a: 1 }`, `{ a: 'x' }` | `{ a: undefined }` | - -`or(option(number), string)` in the old spelling simply flattens to the second -row — `or` is union and the absent bit merges like any other. - -Two sets also become expressible that neither today's design nor a wrapper can -say: `{ a: option }` is "objects with no `a`", and `open({ a: option })` is that -plus anything else — a negative field. - -#### Rendering - -`StructTs` renders a key whose set admits absence as optional, with the absent -bit stripped from what it prints: `or(option, number)` → `readonly a?: number`, -`or(number, undefined)` → `readonly a: number | undefined`. Under -`exactOptionalPropertyTypes` those are already distinct in TypeScript, so the -rendering becomes exact. - -For tuples the trailing run renders optional with the absent bit stripped, and -that rendering is **exact** — the first time `TupleTs` and the schema denote the -same set. `Ts<[1, or(option, number)]>` is `readonly [1, number?]`, and -TypeScript agrees on every row (checked against this repo's `tsc`): - -| value | the schema | `readonly [1, number?]` | -| --- | --- | --- | -| `[1]` | accepts | assignable | -| `[1, 2]` | accepts | assignable | -| `[1, undefined]` | rejects | `TS2322` | -| `[1, 2, 3]` | rejects (closed) | not assignable | - -Reading position `1` still gives `number | undefined`, which is what JavaScript -gives for an index that may not be there, so the type is honest in both -directions. The exactness depends on `exactOptionalPropertyTypes` — with the flag -off, TypeScript accepts `[1, undefined]` at an optional tuple position too -(checked both ways) — and this repo already sets it. - -It takes **both** stages. Stage 1 supplied the length: while a bare tuple was -open, an exact-length rendering was an unsound cast. This stage supplies the -element type: while `option(number)` is `or(number, undefined)`, -the position can only render `(number|undefined)?`, which admits the very -`[1, undefined]` the closed spelling should reject. Together they also make the -two renderers agree — today the runtime printer prints the open tail -(`readonly[number,(undefined|string)?,...readonly(unknown)[]]`, -`../ts/proof.f.mjs`) while `Ts<>` cannot, and afterwards both print -`readonly[1,number?]`. - -There is a **third** renderer over the data form: -`../../media/json/schema/module.f.mjs` derives `required` and `minItems` from -`admitsUndefined`, and drops `undefined` from an optional member's schema with -`stripUndefined`. Stage 2 splits those two uses, which today are one thing: - -- `admitsUndefined` drives `required`/`minItems`, so it asks about **absence** and - moves to the absent bit. Without that, `{ a: or(option, number) }` renders - `required: ["a"]` while RTTI accepts `{}`. -- `stripUndefined` asks what JSON can **carry**, so it stays keyed on `undefined`. - A key of `or(number, undefined)` is then required and renders as `number`: JSON - has no way to write the `undefined` case, so the rendering under-approximates — - the same corner the module already documents for `NaN` and `-0`. - -An *interior* position admitting absence still renders `T | undefined` — -TypeScript forbids a required element after an optional one, and `undefined` is -what TypeScript reading a hole actually gives — so `[or(option, number), 3]` is -`readonly [number | undefined, 3]`. That one stays a rendering limit, not a -narrower set. - -#### The trade, stated - -Legal-but-degenerate spellings replace illegal ones. `array(or(option, number))` -and a top-level `or(option, number)` are meaningless rather than rejected: the -first normalizes to `array(number)`, the second accepts what `number` accepts. -For a set-theoretic form, normalizing beats forbidding — a wrapper would buy -those two errors at the price of a second syntactic category — but it is a trade, -and each degenerate spelling's normal form should be pinned by a proof so it -stays deliberate rather than incidental. - -**The entry node keeps its bit**, and that asymmetry with the rest is deliberate -rather than an oversight. At the entry, `or(option, number)` and `number` accept -exactly the same inputs — nothing can be handed to a call that is not there — yet -they stay structurally distinct, so `equal` is false between them and `subset` -holds only from `number` to the union. They are different *sets*, and this form -compares sets; the entry position simply cannot witness the difference. - -Stripping there instead would cost more than it buys. A rest node has no life -outside its position: it is a field of a pattern, and every value that position -ever sees is a present member, so the bit is vacuous by construction. An entry -node **is the schema** — a `Data` is serializable and a consumer may embed it at -a member position, where the bit is live again. Stripping it at the root would -make `toData` lose information that reappears as a silent meaning change on -reuse, which is worse than an `equal` that answers "different" for two schemas -that behave alike in one position. - -So this joins the same list as the rule-name limit in -[`../data/README.md`](../data/README.md): semantically indistinguishable *here*, -structurally distinct, and content-addressed apart. Stated, not latent. - -On the name: `option` is kept as proposed. It names the modality where `absent` -or `none` would name the value, but as the only spelling it is unambiguous, and -`or(option, number)` reads correctly. Do **not** reintroduce an `option(t)` -helper alongside it — one name, one thing. - -## Tasks - -One PR, now that stage 1 has landed: - -- [ ] `option` as a nullary schema in `../module.f.mjs`/`../types.ts` — a new - `Tag0`, so `visit`'s `Visitor` in `../common/module.f.mjs` gains the case. -- [ ] Give **both** schema-form readers an `option` handler that *rejects* - normally. `orVisit` tries the union's members in order, so for a present - value under `or(option, t)` the `option` branch is reached first and has to - return an ordinary error for `t` to be tried. Extending the `Visitor` type - is not enough to force this: `parseVisitor` (`../parse/module.f.mjs:326`) - and `validateVisitor` (`../validate/module.f.mjs:296`) are both - `/** @type {any} */ ({ … })`, so a missing handler is not a type error but - a `v.option is not a function` throw — and FunctionalScript has no - `try`/`catch` to contain it. Proof: a **present** value under - `or(option, t)`, through both readers. -- [ ] Decide the migration's **semantics** before its spelling. `option(t)` is - `or(t, undefined)` today, so it accepts a present `undefined` — verified, - `validate({ a: number, b: option(string) })({ a: 1, b: undefined })` is - `ok`. Rewriting it to `or(option, t)` therefore **narrows** every migrated - schema; the faithful translation is `or(option, t, undefined)`. This issue - takes the narrowing deliberately — it is what this stage is for, and - `exactOptionalPropertyTypes` already rejects the present-`undefined` - spelling at an optional key — but each production site is reviewed rather - than swept, and the changelog says the schemas got stricter, not that a - spelling changed. -- [ ] Migrate the **documentation and instructions** too, which the compiled-call - sweep does not reach and no checker flags. A missed call is `TS2554` at - build time; a missed doc is a working example that quietly builds the wrong - schema for whoever copies it. Twenty sites across eight files: - `../README.md` (3), `../ts/README.md` (2), `../data/README.md` (3), - `../../protocol/mcp/README.md:71` (a copy-me - `greeting: option(string)`), `../../media/revision/README.md` (5, - including the `option(true)` presence-flag idiom it recommends twice), - `../../media/note/README.md` (2), - `../../media/note/todo/extend-note-format.md` (2), and - `../../AGENTS.md` — `:383` writes `option(...)` among the schema - references, a call form it stops having, while `:405` lists `option` as a - bare name among `rtti`'s exports, so that one is a description to - re-word rather than a spelling to fix. Two near-misses stay out: `option` in - `../../bnf/todo/207.md` is `bnf`'s own combinator, and the `option(s)` - in `../../cas/evo/todo/cache-staleness.md` is English, not code. -- [ ] The **JSDoc** sites, which that list does not cover: it is a markdown - inventory, and a comment is no more compiled than a `.md` file is, so the - two sweeps between them still leave these eleven untouched, in six files. - Two of them are not spellings but *statements of the semantics stage 2 - replaces*, and matter more than the rest: `../ts/proof.f.mjs:27` says - "`option(t)` is `or(t, undefined)`; these are the schema types it - produces", which is the definition this stage retires, and - `../data/module.f.mjs:453` argues a design decision from - "`{ a: option(number) }` a subset of `record(number)`, which admits - `{ a: undefined }` on the left" — the same claim that flips in - `../data/proof.f.mjs:616` above, so the rationale and the row have to move - together or the code will justify itself with a false example. - `../ts/module.f.mjs:325` asserts the printer's output for a schema - (`option(number)` prints `'undefined|number'`), which stops being true. - `../ts/types.ts` (`:80`, `:139`, `:162`, `:163`, `:167`) uses `option(x)` - as the optional-member spelling throughout the `TupleTs`/`OptionalFields` - derivation. `../validate/module.f.mjs` (`:18`, `:298`) publishes - `b: option(string)` in its parse-vs-validate contrast and in the exported - `validate`'s `@example` — copy-me code in the reader's own API docs. - `../../media/revision/proof.f.mjs:125` names the `option(true)` - presence-only idiom its README recommends. Sweep JSDoc explicitly rather - than trusting the markdown pass: the earlier revision of this item said - "twenty sites across eight files" and meant twenty *markdown* sites, which - review caught. The markdown count stands; the scope did not. -- [ ] Audit the members that spell optionality **directly** as `or(…, undefined)`, - which the `option(` sweep does not reach and `checkJs` cannot flag — they - stay syntactically valid and silently become *required*. Verified sites: - `mcp/cas/module.f.mjs:146` (`type: or('text', 'base64', undefined)`, so - `cas_add` would start rejecting `{ content: 'hello' }`), - `media/json/schema/module.f.mjs:56` and `:60` (`type`, `items`), plus - `media/json/schema/proof.f.mjs:121` and `:136`. Each is a decision — add - `option` where omission was intended, leave it where a present `undefined` - was — not a mechanical rewrite. -- [ ] One of those decisions has a **second copy in a surviving todo**: - [checked-const-pin](./checked-const-pin.md) `:14` and `:36` quote - `casAddArgs` — the `mcp/cas/module.f.mjs:146` schema above — twice, as the - motivating example for its own proposal. It is not a call site, so neither - the `option(` sweep nor `checkJs` reaches it, and it outlives stage 2. If - the CAS decision goes to `or(option, 'text', 'base64')`, the todo would be - left arguing from a schema whose `type` key is now *required*, which is - the opposite of what its example illustrates. Rewrite both quotes to - whatever that decision picks, in the same PR — the point it makes about - unchecked `as const` pins is untouched either way. -- [ ] Migrate every `option(t)` call site to `or(option, t)` — 52 of them across - 10 files in 9 modules outside this one (`protocol/mcp` 10, - `media/json/schema` 11 plus 11 in its proof, `ci/common` 5, `mcp/evo` 5, - `protocol/json_rpc` 3, - `media/revision` 2, `media/note` 2, `mcp` 2, `mcp/cas` 1), plus this - module's own proofs. The repo sets `checkJs`, so a missed site is - `TS2554: Expected 0 arguments, but got 1` rather than a silent - absence-only schema — verified — but the schemas are wrong until migrated. -- [ ] `../data/module.f.mjs`: give `thunkUnion` an explicit `'option'` case - returning `{ unit: absentBit }`. Its switch ends in - `default: { return orUnion(state, t, rest) }`, and a nullary tag has an - empty `rest`, so without the case `toData(option)` is the empty union — - `never` — and `toData(or(option, number))` silently loses the bit. Every - data-side rule below then operates on a bit nothing ever sets. The tag - enumerations are independent: adding `option` to `Tag0` does not reach this - switch. Pin `toData(option)` and `toData(or(option, number))`. -- [ ] `../data/types.ts:70-73` states the public contract that stage 2 breaks: - "`unit` is a bitset over the four singleton values; bit `1 << i` stands for - `unitList[i]` … (`['null', 'undefined', 'false', 'true']`)". A fifth bit - maps to no `unitList` entry, and the form is *serializable*, so a consumer - decoding stored data by that sentence cannot read bit 16 at all. Document - the absence bit there and in `unitList`'s own JSDoc, saying why it is not a - `unitList` member — it is not a DJS value. -- [ ] `../data/module.f.mjs`: `absentBit` as the fifth unit bit — `unitBit` stays - value-keyed, since the new bit has no JS value to key on — and `trimPrefix` - and `objectMayOmit` switch to it. `allUnits` stays the four DJS units; - `or(option, unknown)` is the declared-member top. -- [ ] Normalize the absent bit out of an **inline** `rest` on both kinds; pin - `array(or(option, number))` → `array(number)` and the top-level spelling. -- [ ] `objectPresentSet` strips the absent bit too — it answers "what may be - **present** at this key", while `objectMayOmit` answers whether the key may - be missing, and `objectSetSubset` calls both. Left unstripped, the closed - `{ a: or(option, number) }` tests `(Absent | number) ⊆ number` against - `record(number)` and answers false, though its only values are `{}` and - `{ a: number }`, both of which `record(number)` admits — so coverage - collapse stops firing and equivalent unions stay structurally unequal. - `../data/proof.f.mjs:616` is the row that **flips**: - `assert(!subset(toData({ a: option(number) }))(toData(record(number))))`, - correct today because `option(number)` admits `{ a: undefined }`, wrong - once it does not. Its comment — "the open-struct spelling, which was sound - before, still is" — has to change with it. -- [ ] Split the **array** position test the same way, which the struct strip - above does not reach. `arraySetSubset` (`../data/module.f.mjs:425-436`) - hands each left position straight to `nodeSubset` — - `p.prefix.every((el, i) => le(el, qAt(i)))` — so the absent bit is compared - as an ordinary member and closed `[or(option, number)]` ⊆ `array(number)` - answers false, though the left's only values are `new Array(1)` and - `[number]` and `array(number)` admits both (it walks own entries, so it - accepts a hole). Give the position the two questions the object kind - already asks: compare the **absence-stripped** left set against `qAt(i)`, - and separately require that the right admits absence at `i` when the left - does — which it does when `i >= q.prefix.length` (a hole there is no entry) - or when `q.prefix[i]` carries the bit. The left's own `rest` needs neither, - since a rest carries no absent bit after normalization. Pin - `[or(option, number)]` ⊆ `array(number)` as **true** and - `[or(option, number)]` ⊆ `rest([number], never)` as **false**, the pair - that tells the two halves apart. -- [ ] Restate `arraySetSubset`'s doc comment with it. Its "the shortest needs no - test of its own" argument is spelled in terms of `undefined` membership — - "otherwise `undefined` would be a member of `p.prefix[i]` and not of - `q.prefix[i]`" — and after stage 2 the property it needs is the absent bit, - not `undefined`. The absence-implication check above *is* that argument - made explicit, so the comment should point at it rather than restate the - old reason. -- [ ] Leave a **referenced** `rest` unstripped, and have `subset` **resolve** it - rather than mask the bit — masking is unsound where the reference's present - part is empty (see above), so there is no context in which the mask is the - rule. Expect one-way inclusion there, not mutual: `rest(c, X')` ⊆ - `rest(c, X)` when `X` is absence-only, since the stripped form bounds the - length and the syntactic one does not. Pin `X = or(option, array(X))` used - as a rest for the non-empty case, the absence-only cycle for the empty one, - and add both to `../data/README.md`'s list of accepted structural - incompleteness. -- [ ] The same exemption covers a referenced **trailing position**, which the - redesigned `trimPrefix` reaches independently: for mutually recursive - `X`/`Y` where `X` normalizes to `or(option, number)`, - `toData(rest([X], number))` stores the prefix as `"X"`, and neither - `trimPrefix` nor `arraySet` takes a rule set to resolve it with (verified — - both are `(prefix, rest) => …`). So a referenced trailing position is left - untrimmed by the same rule that leaves a referenced rest alone, and - `rest([X], number)` stays structurally distinct from `array(number)`. Pin - it beside the rest case rather than leaving it to be discovered. -- [ ] Redesign `trimPrefix` around the trailing **declared position** — drop it - when it admits absence and its absence-stripped set equals the rest — and - pin `rest([or(option, number)], number)` as `array(number)`. A bit test on - the rest is dead once rests carry no absent bit. -- [ ] Except when **both** the stripped position and the rest are empty. A bare - `[option]` is closed, so its rest is `never` and its sole position strips - to `never` too — the rule above would drop the position and normalize it to - `[]`. Those are different sets: `[option]` accepts `new Array(1)` (index 0 - absent, length within the declared prefix) and `[]` rejects that length, so - the trim would make the data form disagree with the thunk readers and have - `equal`/`cmp` identify two array sets that differ. Pin `[option]` against - `new Array(1)` and `[]`. -- [ ] Make `isTop` position-aware: `or(option, unknown)` for a declared member, - `unknown` for a `rest`. Keep `objectSet`'s `r === undefined` guard, and pin - `{ a: or(option, unknown) }` (closed) as objects with at most the key `a`. -- [ ] Absence is decided by the **container loop, before dispatch** — it cannot - be decided by the recursive reader, which is handed only the value read. - `constContainerValidate`/`constContainerParse` call - `validate(v)(getItem(value, k))`, so an absent key arrives as plain - `undefined`; with the `option` handler rejecting normally (below), both - branches of `or(option, number)` would reject `{}`. So `common` gains an - `admitsAbsence(schema)` predicate and each container loop asks it first: - a member whose key or index is not an own one succeeds iff its schema - admits absence, and only a present member is dispatched. The predicate - **traverses nested unions**, with a visited set for cycles: schema-form - `or` does no - flattening (its own doc says so), so `or(or(option, number), string)` has - no `option` among its direct members while admitting absence, and a - shallow test would reject `{}`. It descends `or` nodes and the thunks they - hold, stops at any other tag, and carries the visited thunks to terminate - on a recursive `X = or(option, X)`. The data form needs none of this - *traversal* — `toData` has already flattened, which is why `objectMayOmit` - can read one bit — so the thunk side pays for being the reader that does no - preprocessing. -- [ ] The data **reader** still needs its own absence path, which `objectMayOmit` - does not supply: that function is used only by `subset` - (`data/module.f.mjs:475`, called once at `:500`), while - `arraySetValidate` and `objectSetValidate` dispatch each declared position - as `nodeValidate(rules)(n)(value[Number(k)])` — the value read, with no - ownership, exactly like the thunk loops. Give both container loops the same - before-dispatch test, or the data reader rejects `{}` and sparse tuples - that both thunk readers accept, and `validate/proof.f.mjs`'s three-reader - table breaks. -- [ ] A member absent by own-key but supplied by the **prototype** must still - satisfy the member's present part, or `validate`'s success type goes - unsound — and this is a regression the own-key rule introduces, not a - corner it inherits. Measured today: - `validate({ a: option(number) })(Object.create({ a: 'bad' }))` is an - **error**, because `getItem` reads through the prototype and checks - `'bad'` against `number`. Under the own-key rule alone it becomes `ok`, - and the returned object — `validate` hands back what it was given, so it - cannot sanitize by rebuilding as `parse` does — reads `.a` as `'bad'` - while `Ts` promises `number | undefined`. So the absence test rejects when - `Object.hasOwn` is false, HasProperty is true, and the inherited value is - outside the member's present set. Proof: that exact value against - `{ a: option(number) }` and `{ a: option(string) }`, which today answer - error and ok respectively. -- [ ] Readers: a declared member is absent when its key or index is not an own - one. `parse` omits an absent member rather than materializing `undefined`: - the struct kind drops the key, and the array kind **preserves indices** — - a trailing absent run shortens the result, an interior one stays a hole. - `arrayRebuild` is `entries => entries.map(([, v]) => v)`, so omitting an - absent entry would rebuild `[, 3]` as `[3]`, shifting `3` to index 0 and - returning a value that fails its own schema. -- [ ] The array kind's rebuild is **slice, then map**, and mapping alone is not - enough: `.map` preserves length, so `[1, or(option, number)]` against - `[1, ,]` would rebuild a sparse two-element array and serialize back to - `[1,null]` — the very defect stage 2 removes. Truncate to the last present - declared position first, then map the *parsed* element results over the - truncated array, so a trailing absent run shortens the result while an - interior hole survives. FunctionalScript's rules leave no other route: - `Array.from({ length }, …)` yields a dense array and there is no index - assignment or mutation. Verified: `[1, , 3].slice(0, 3)` keeps the hole at - 1, `[1, , ,].slice(0, 1)` is `[1]` with length 1, and a `.map` after either - keeps the hole. -- [ ] Drive that rebuild by the **own**-index test the check uses, not by - `slice`/`map` alone. Both use HasProperty, so an index the value only - *inherits* is materialized as an own property of the result — measured, - with `Array.prototype[0]` defined, `[, 3].slice(0, 2)` and - `[, 3].map(v => v)` both give `["PROTO", 3]` with - `Object.hasOwn(result, 0)` true. That contradicts this stage's own rule - and can rebuild a value the schema rejects. The same measurement settles - the test to use: `0 in [, 3]` is **true** once the prototype supplies the - index, so it is `Object.hasOwn`, never `in`, while `Object.entries` stays - own-only. Reachable only from plain JavaScript — FunctionalScript has - neither mutation nor prototype writes — so it constrains the construction - rather than rejecting the slice-then-map shape, on the same footing as the - overridden-`Symbol.iterator` case `../common/module.f.mjs` documents and - the beyond-`length` caveat `../README.md` states. -- [ ] State the bound rather than implying a construction that does not exist: - against a prototype-supplied index, **no** immutable builder can produce - the hole. An `Object.hasOwn` guard *inside* the callback does not help — - `.map` creates the own output element whatever the callback returns - (measured: the guarded map still gives `hasOwn(result, 0)` true) — and a - fresh `Array(n)` inherits the index too, so it is no cleaner source. The - escapes are `Object.assign` or index assignment, both mutation, both - forbidden. So `parse` materializes the inherited value in that case, and - `validate` is untouched because it returns the value it was given. Pin it - as a bounded divergence, unreachable from FunctionalScript, rather than - leaving the task reading as though a sanitized source were available. -- [ ] `../ts/module.f.mjs`, the **runtime printer**: `arraySetToTs` and - `objectSetToTs` decide optionality through their own `admitsUndefined` - (`:159`, `:184`, `:217`), so without this `{ a: or(option, number) }` and - `[1, or(option, number)]` print required members while `Ts<>` and both - readers treat them as optional — and the two-renderer pin below could not - hold. Move them to the absent bit and update `../ts/proof.f.mjs`. -- [ ] `../../media/json/schema/module.f.mjs`: move `admitsUndefined` (and so - `required`/`minItems`) to the absent bit, leave `stripUndefined` on - `undefined`, and update `./proof.f.mjs` — a third renderer over the data - form, and the one whose output is wrong rather than merely imprecise if it - is missed. -- [ ] `../ts/types.ts`: "strip the absent bit" is data-form vocabulary and does - not apply here — `OptionalFields` keys on `undefined extends Ts`, so - the type level sees members already reduced through `Ts`. Map `option` to a - **branded uninhabited marker** (`Absent`, a `unique symbol` brand). Not - `never`, which vanishes in a union and takes the information with it; not - `undefined`, which would make `or(undefined, number)` optional too and - conflate the pair this stage exists to separate. Then `OptionalFields` keys - on **`_AdmitsAbsence`, a structural predicate over the schema**, and - renders `Exclude<_TsRaw, Absent>` for the value. The test cannot be - a subtype query against the rendered union — neither `Absent extends Ts<…>` - (which excludes the marker itself, so it is false for every member) nor - `Absent extends _TsRaw`, which fails in the other direction at - `unknown`: `_TsRaw` is `unknown`, `Absent extends unknown` - is true for any `Absent`, and `unknown | Absent` is `unknown` — all three - measured. So the closed `{ a: unknown }`, which stage 2 *rejects* `{}` for, - would render `a?:`, and would be indistinguishable from - `{ a: or(option, unknown) }`, which the runtime printer does tell apart. - The marker is absorbed by the top and cannot be recovered from the union - it lands in; only the schema still carries the fact. `_AdmitsAbsence` - recurses through `or` — which does no flattening, so - `or(or(option, number), string)` needs the recursion, the same reason the - runtime `admitsAbsence` is not a one-level scan. Pin `{ a: unknown }` - required and `{ a: or(option, unknown) }` optional with - `Assert>`, the pair that fails under either subtype query; - `ArrayTs`/`RecordTs` `Exclude` it from their element type, so - `Ts` stays `readonly number[]` — the type-level - counterpart of "a rest never sees it" — except that `ArrayTs` emits - `readonly []` when the exclusion leaves `never`, since `readonly never[]` - is *not* the empty array: `readonly never[] = new Array(1)` - type-checks and its `.length` is `number`, while `readonly []` rejects it - ("Target allows only 0 element(s)") and its `.length` is `0` — both - measured. `array(option)` is the empty array (see "Length still bounds a - closed array" above), so without the case the compile-time renderer is - wider than the runtime one on the schema that section exists to settle. - Pin `Ts`. `RecordTs` needs no counterpart: - `Record` already admits `{}` and nothing else, because an - object type carries no length to disagree about. Split the transformer to keep the - marker internal: `_TsRaw` preserves it for the container mappings to - read, and the public `Ts` is `Exclude<_TsRaw, Absent>`. Excluding it - only in the reader results would leave `Ts` as - `Absent | number` for direct consumers and for `Check`, a union no runtime - value can inhabit and one the runtime printer has no way to spell. With the - split, `Result` needs no exclusion of its own — "observable only at a - container position" falls out of the public entry. -- [ ] Lower the marker **per position**, not with one outer `Exclude`. A tuple - type is not a union, so `Exclude<_TsRaw, Absent>` never reaches inside - it: `_TsRaw<[or(option, number), 3]>` keeps `Absent | number` at index 0 - and the public `Ts` would hand a consumer the uninhabitable marker. Each - position lowers it for itself — a struct key and a trailing tuple position - that admit absence render optional with `Absent` excluded; an **interior** - tuple position replaces `Absent` with `undefined`, which is what reading a - hole gives and the only spelling TypeScript allows before a required - element; an array/record element excludes it. The runtime printer needs the - same conversion for the interior case: switching `admitsUndefined` to the - absent bit alone makes `arraySetToTs` print `number` where it owes - `number | undefined`. Update the - `_tupleOption`/`_tupleInteriorOption` pins, and `optionalTuplePosition` / - `allOptionalTuple` in `../ts/proof.f.mjs`, which print the `undefined|` - this stage removes. -- [ ] Say which vocabulary a **`Phantom` annotation** is written in. `Ts`'s - phantom branch (`T extends { readonly [phantomKey]?: infer O } ? Exclude`) returns the annotation *before* the thunk walk — that is what - spares recursive schemas TS2589 — so a phantom-wrapped schema whose root - admits absence would otherwise render required. Moving the walk ahead of - the fast path would bring TS2589 back, so instead the annotation is - `_TsRaw`-shaped: it carries `Absent` when the schema's root admits absence, - the branch keeps `Exclude` (which strips the optional-field - artifact, not the marker), and the public `Ts` strips `Absent` as it does - everywhere else. Enforcing it needs a **new assert**: the existing pair - compares through public `Ts`, which strips `Absent` from both sides, so - `Check3` passes even when `_TsRaw` - is `Absent | number` and the annotation says `number` — and the member then - renders required. Add a `_TsRaw`-level check (`CheckRaw = Equal>`) for the raw half, since that is the only half with teeth - here — and update the **contract that mandates the weak pair**: - `../../types/phantom/types.ts:26-38` tells every `Phantom` user to guard with - two `Check`s "or `Check3`, which pairs the two into one assert", both of - which route through public `Ts`. A caller following that documentation - after stage 2 silently renders a wrapped optional member required. The - JSDoc has to require the raw assert and say how a caller spells it, which - means `Absent` and `CheckRaw` become part of the exported surface rather - than internal names. Runtime is untouched: a `Phantom` has no runtime representation, so - `admitsAbsence` - walks the same thunk either way. Proof: an optional `Phantom`-wrapped - member. -- [ ] Pin `Ts<[1, or(option, number)]>` as `readonly[1,number?]` from both - renderers, with the four assignability rows above — the exactness claim is - the point of the two stages and should fail loudly if it regresses. -- [ ] Proofs: `{}` separated from `{ a: undefined }`; `[, 3]` accepted and - `[undefined, 3]` rejected for `[or(option, number), 3]`; the JSON - round-trip case from - [parse-omits-undefined-members](./parse-omits-undefined-members.md); - `{ a: option }` as a negative field. Delete the pin this abolishes: - `../validate/proof.f.mjs:376`, `every(rtti)(assertOk)([undefined, 5])` - commented "the same value, spelled densely", run against - `[option(string), number]` through all three readers and through - `open(t)` — under this stage that value is present-`undefined` at position 0 - and is no longer the same value as `[, 5]`. Assert on the **built value**, not - only acceptance: `parse([or(option, number), 3])([, 3])` has no own index - `0` and carries `3` at index `1`. -- [ ] Delete [parse-omits-undefined-members](./parse-omits-undefined-members.md); - restate the absence rule in `../README.md` and `../data/README.md` as the - absent bit rather than as `undefined`. -- [ ] Changelog: **BREAKING CHANGES:** `option` is a nullary schema denoting - absence. `option(t)` becomes `or(option, t)`, which also **narrows**: a - schema that accepted a present `undefined` at that member no longer does. - `parse` no longer materializes an absent member. - -## Related - -- [parse-omits-undefined-members](./parse-omits-undefined-members.md) — the - construction ambiguity and the array kind's JSON defect; stage 2 dissolves both - and deletes the file. -- [schema-walk-own-indices](./schema-walk-own-indices.md) — how a tuple *schema* - is walked; stage 2 settles the same question for the *value*, so land them in a - consistent order. -- [`../data/README.md`](../data/README.md) — the declared-key/undeclared-entry - asymmetry, which is this stage's premise, and the two kinds' opposite identity - elements, which stage 1's mapping already turned over. -- [`../ts/types.ts`](../ts/types.ts) — `TupleTs`'s derivation and - `OptionalFields`, the two renderings this stage changes; `RestTs` is stage 1's - half of the same rendering. -- [excluded-string-values](./excluded-string-values.md) — the other proposed `Type` - ADT extension, and the bar it sets: a data-form mapping worked out end to end - before code. -- [#1719](https://github.com/functionalscript/functionalscript/pull/1719) — - **collides with both stages.** The epic makes RTTI the single source of truth - for the type system and works its examples in the eDSL as it stands today — - `close([t, t])` and `option(key)` — every one of which this proposal - respells. Its stage list is unaffected; its worked examples are not. diff --git a/fjs/rtti/todo/parse-omits-undefined-members.md b/fjs/rtti/todo/parse-omits-undefined-members.md deleted file mode 100644 index 7d2533f3c..000000000 --- a/fjs/rtti/todo/parse-omits-undefined-members.md +++ /dev/null @@ -1,168 +0,0 @@ -# `parse` builds the members it should omit - -**Priority:** P2 — the array kind's JSON round-trip is a data defect, not just -a canonicality gap -**Status:** open — both halves are now unblocked; what remains is the change -itself (see [The type-level obstacle is gone](#the-type-level-obstacle-is-gone)) - -## Problem - -RTTI has one rule for absence, stated in [`../README.md`](../README.md) for -both container kinds: an absent member reads as `undefined`, so **a member is -required exactly when its set excludes `undefined`**. Absence *is* `undefined` -— the two are one thing, which is why `[number, option(string)]` accepts -`[42]` and `{ a: number, b: option(string) }` accepts `{ a: 1 }`. - -`parse` reads that rule on the way in and then contradicts it on the way out. -It materializes the member it just decided was absent (verified at `d24983a`): - -| schema | value | `parse` builds | -| --- | --- | --- | -| `[number, option(string)]` | `[42]` | `[42, undefined]` | -| `{ a: number, b: option(string) }` | `{ a: 1 }` | `{ a: 1, b: undefined }` | -| `rest([number, option(string)], string)` | `[1]` | `[1, undefined]` | - -Both spellings denote the same RTTI value, and `parse` picks the one that -spells absence as a present member. `validate` has nothing to pick — it returns -what it was handed — so the disagreement is `parse`'s alone. - -### It breaks a JSON round-trip on the array kind - -JSON has no `undefined`, and an array element that holds one serializes as -`null`. So the value `parse` builds does not survive the format it was most -likely read from: - -```js -const s = [number, option(string)] -parse(s)([42]) // ['ok', [42, undefined]] -JSON.stringify([42, undefined]) // '[42,null]' -parse(s)([42, null]) // ['error', { path: ['1'], message: 'no match' }] -``` - -The omitted spelling round-trips: `'[42]'` re-parses to `['ok', [42, undefined]]`. - -The struct kind happens to work, because `JSON.stringify` already drops a key -whose value is `undefined` — it applies the very rule this issue asks `parse` -to apply. So today the two kinds disagree about their own output in a way -nothing in the module states, and the kind that disagrees loses data. - -The same follows for any format without `undefined` (CBOR, and the canonical -byte-level forms `../../cas` hashes): two values that are equal under RTTI -serialize differently, so they address differently. - -## Proposal - -**Omit, don't materialize.** A parsed member whose value is `undefined` is not -written into the result. - -- **Struct kind** — drop the key. `parse({ a: number, b: option(string) })({ a: 1 })` - builds `{ a: 1 }`, and `'b' in result` is false. -- **Tuple kind** — drop the **trailing run** only. An interior position cannot - be dropped without shifting the positions after it, so an interior - `undefined` stays an explicit element: `[number, option(string), number]` - against `[1, undefined, 3]` builds `[1, undefined, 3]` unchanged, while - `[number, bigint, option(string), option(null)]` against `[2, 4n]` builds - `[2, 4n]`. -- The closed forms follow, building their declared members exactly as the open - ones do. - -Where it lands: `arrayRebuild` and `recordRebuild` in -[`../parse/module.f.mjs`](../parse/module.f.mjs) are the two rebuild functions -all the container factories share, so each kind changes in one place. - -Two cases the implementation must answer rather than discover, both settled by -the same rule — `undefined` is absence, whatever put it there: - -- A member whose *input* is explicitly `undefined` (`{ a: 1, b: undefined }`) - and a member declared with a set that is only `undefined` (`{ a: undefined }`, - or a `{ a: unknown }` whose `a` is `undefined`) are dropped too. -- `array`/`record` share those rebuilds, so the rule reaches them unless it is - gated per kind. Uniform is the position this issue takes — `ArrayTs` is an - unbounded `ReadonlyArray` and `RecordTs`'s keys are already optional, so - neither costs anything at the type level — but it is a decision, not a - side effect to leave unstated. - -### The type-level obstacle is gone - -Both halves are now free at the type level. - -The struct half always was. `StructTs` renders an admits-`undefined` key as -optional (`OptionalFields` in [`../ts/types.ts`](../ts/types.ts)) and keeps -`undefined` in the value type, so `{ a: 1 }` and `{ a: 1, b: undefined }` are -both assignable under this repo's `exactOptionalPropertyTypes: true`. - -The tuple half was the blocker: `TupleTs` mapped a schema tuple to a -**required-length** tuple, so a dropped result would not have inhabited its own -declared type — - -``` -error TS2322: Type '[number]' is not assignable to type 'readonly [number, string | undefined]'. - Source has 1 element(s) but target requires 2. -``` - -`TupleTs` now renders the trailing admits-`undefined` positions optional, so -`Ts<[number, bigint, option(boolean), option(string)]>` is -`readonly[number, bigint, (boolean|undefined)?, (string|undefined)?]` and both -spellings — `[1, 2n]` and `[1, 2n, undefined, undefined]` — inhabit it. The -derivation and the three errors it had to defeat are in `TupleTs`'s doc -comment; `_tupleOption` and `_tupleInteriorOption` pin the rendering. - -That was the one thing this issue needed decided before it could proceed. What -is left is the change itself, plus one question it does not settle: -`array`/`record` share `parse`'s rebuilds, so the rule reaches them unless it -is gated per kind (this issue says uniform — `ArrayTs` is an unbounded -`ReadonlyArray` and `RecordTs`'s keys are already optional, so neither costs -anything at the type level). - -Only the *trailing* run renders optional, because TypeScript forbids a required -element after an optional one. That is a spelling limit, not a narrower set: an -interior position admitting `undefined` may still be absent at runtime, which -`../validate/proof.f.mjs`'s `interiorOptionBeforeRequired` pins — -`[option(string), number]` accepts `[, 5]`, the required position after the -hole being present. `optionalPositions` cannot say it: its hole falls *inside* -the trailing omittable run, so no position the renderer marks required follows -it. (Not that truncation explains that row — truncation would predict its -rejection, and all three readers accept it, which is what that proof's own -comment records.) - -## Tasks - -- [x] Decide the tuple half — `TupleTs` renders trailing omittable positions - optional, so neither kind is blocked at the type level any more. -- [ ] Omit in `arrayRebuild`/`recordRebuild`: drop the key on the struct kind, - the trailing `undefined` run on the array kind; open and closed alike. -- [ ] Settle whether `array`/`record` follow (this issue says yes). -- [ ] `../README.md`: the two-readers table row "absent optional member" - (`parse`: "present as `undefined`") and the openness row - `[number, option(string)] | [42] | [42, undefined]`. -- [ ] The proofs that pin the current spelling: - `../parse/proof.f.mjs`'s `shortArrayFillsAnOptionalPosition` and the - closed `shortArray`, and `../validate/proof.f.mjs`'s - `absentOptionalStaysAbsent`, whose contrast assertion is - `'b' in unwrap(parse(schema)(input))`. -- [ ] Add the JSON round-trip above as a proof case, so the defect cannot - return unnoticed. -- [ ] Changelog: **BREAKING** — `parse` no longer materializes an absent - optional member. - -## Related - -- [`../parse/module.f.mjs`](../parse/module.f.mjs) — `arrayRebuild` / - `recordRebuild`, the two rebuild points. -- [`../README.md`](../README.md) — "Structs and tuples are open" states the - absence rule this issue applies to construction, and "The two schema-form - readers" tabulates the row that changes. -- [`../ts/types.ts`](../ts/types.ts) — `TupleTs` (the optional-position - derivation, and the errors it defeats) and `OptionalFields` (the struct - half's). -- The same "a hole and a declared `undefined` are one thing" question from the - *schema* side, which this issue asks from the *value* side. It shipped as - [#1712](https://github.com/functionalscript/functionalscript/pull/1712) — - `parse` and `validate` read a tuple schema by length, so a hole in one is a - declared position whose schema is `undefined`. That settles the schema side - in favour of the reading this issue assumes, and leaves - [schema-walk-own-indices](./schema-walk-own-indices.md) as what remains of - it: whether that walk goes by own indices or by iteration. -- [PR #1708](https://github.com/functionalscript/functionalscript/pull/1708) — - added the acceptance rows for several trailing optional positions, which is - where the construction side came up. diff --git a/fjs/rtti/ts/README.md b/fjs/rtti/ts/README.md index 47c7c3db6..50826ceab 100644 --- a/fjs/rtti/ts/README.md +++ b/fjs/rtti/ts/README.md @@ -68,8 +68,8 @@ export const unknown: WithOut = unknownThunk // unknownConst is defined after `unknown` so it can reference `unknown` recursively. // The thunk defers evaluation, breaking the circular reference at runtime. const unknownConst = { - not: option(unknown), - anyOf: option(array(unknown)), + not: or(option, unknown), + anyOf: or(option, array(unknown)), // ... } as const diff --git a/fjs/rtti/ts/module.f.mjs b/fjs/rtti/ts/module.f.mjs index 1a8adc0f1..bc28a044b 100644 --- a/fjs/rtti/ts/module.f.mjs +++ b/fjs/rtti/ts/module.f.mjs @@ -22,7 +22,7 @@ import { assertNotNullish } from '../../asserts/module.f.mjs' import { reservedWords, strictModeReservedWords } from '../../js/keywords/module.f.mjs' import { at, definedEntries } from '../../types/object/module.f.mjs' import { primitive, union, printer as tsPrinter } from '../../types/ts/module.f.mjs' -import { cmp, never as bottom, toData, unitBit, unknown as top } from '../data/module.f.mjs' +import { absentBit, cmp, never as bottom, toData, unitBit, unknown as top, withoutUnits } from '../data/module.f.mjs' const nullBit = unitBit(null) const undefinedBit = unitBit(undefined) @@ -148,10 +148,14 @@ const unitToTs = bits => [ * `readonly[A,...readonly(R|undefined)[]]`. * * A position the array may simply end before prints optional — the trailing - * run whose sets admit `undefined`, which is exactly what the array may stop + * run whose sets admit **absence**, which is exactly what the array may stop * at, arrays being contiguous. It mirrors the optional key `objectSetToTs` - * prints, and keeps the union rather than stripping `undefined` from it, as - * that one does. + * prints, with the absent bit stripped from what it prints (`unionToTs` + * masks it), so `[1, or(option, number)]` prints `readonly[1,(number)?]` — + * exact under `exactOptionalPropertyTypes`. An *interior* position admitting + * absence prints `undefined | T` instead ({@link interiorToTs}): TypeScript + * forbids a required element after an optional one, and `undefined` is what + * reading a hole gives. * * The **tail** admits `undefined` on top of what the `rest` states, because a * hole past the prefix is no member: the readers check each present member @@ -170,11 +174,9 @@ const unitToTs = bits => [ * @type {(ctx: _Ctx) => (p: ArraySet) => string} */ const arraySetToTs = ctx => p => { - const required = p.prefix.findLastIndex(n => !admitsUndefined(ctx)(n)) + 1 - const items = p.prefix.map((n, i) => { - const ts = nodeToTs(ctx)(n) - return i < required ? ts : `(${ts})?` - }) + const required = p.prefix.findLastIndex(n => !admitsAbsence(ctx)(n)) + 1 + const items = p.prefix.map((n, i) => + i < required ? interiorToTs(ctx)(n) : `(${nodeToTs(ctx)(n)})?`) const { rest } = p if (rest === undefined) { return ctx.ts.tuple(items) } const restTs = nodeToTs(ctx)(rest) @@ -195,13 +197,44 @@ const resolveNode = ctx => n => typeof n === 'string' ? assertNotNullish(at(n)(ctx.rules)) : n /** - * Whether the node's value set admits `undefined` — its unit bit. + * Whether the node's value set admits `undefined` — its unit bit. Still the + * tail's question (`rest([42], string)` accepts `[42, , ]`, and index 1 + * reads `undefined`); optionality of a declared member is + * {@link admitsAbsence}'s. * * @type {(ctx: _Ctx) => (n: Node) => boolean} */ const admitsUndefined = ctx => n => ((resolveNode(ctx)(n).unit ?? 0) & undefinedBit) !== 0 +/** + * Whether the node's set admits **absence** — its absent bit, read through a + * reference if needed. What decides a declared member's optionality. + * + * @type {(ctx: _Ctx) => (n: Node) => boolean} + */ +const admitsAbsence = ctx => n => + ((resolveNode(ctx)(n).unit ?? 0) & absentBit) !== 0 + +/** + * An **interior** tuple position: one that admits absence prints + * `undefined | T` — TypeScript forbids an optional element before a required + * one, and `undefined` is what reading a hole gives — and any other prints + * as it is. An inline node converts by bit, so the `undefined` merges into + * the union's canonical order; a reference prints its identifier with + * `undefined` unioned in front. + * + * @type {(ctx: _Ctx) => (n: Node) => string} + */ +const interiorToTs = ctx => n => { + const bits = resolveNode(ctx)(n).unit ?? 0 + if ((bits & absentBit) === 0) { return nodeToTs(ctx)(n) } + if (typeof n === 'string') { + return union([primitive(undefined), nodeToTs(ctx)(n)]) + } + return unionToTs(ctx)({ ...n, unit: (bits & ~absentBit) | undefinedBit }) +} + /** * Whether the node's value set is empty. * @@ -213,9 +246,11 @@ const isNever = ctx => n => cmp([{}, resolveNode(ctx)(n)])([{}, bottom]) === 0 const dedup = list => list.filter((s, i) => list.indexOf(s) === i) /** - * A struct prints its fields — a key whose value set admits `undefined` may - * also be absent, so it prints optional, mirroring `Ts<>` — and a record - * prints its value type. A props-with-rest set combines them with an + * A struct prints its fields — a key whose value set admits **absence** + * prints optional, with the absent bit stripped from what it prints + * (`unionToTs` masks it), mirroring `Ts<>`: `or(option, number)` is + * `readonly a?: number`, and `or(number, undefined)` is the required + * `readonly a: undefined|number` — and a record prints its value type. A props-with-rest set combines them with an * intersection; TypeScript requires an index signature to cover the * declared keys too, so the index type widens to the union of the rest and * the declared value types — the closest expressible supertype. @@ -232,7 +267,7 @@ const objectSetToTs = ctx => p => { /** @type {readonly StructField[]} */ const fields = definedEntries(p.props).map(([k, v]) => { const ts = nodeToTs(ctx)(v) - return admitsUndefined(ctx)(v) ? [k, ts, true] : [k, ts] + return admitsAbsence(ctx)(v) ? [k, ts, true] : [k, ts] }) const { rest } = p if (rest === undefined || isNever(ctx)(rest)) { return ctx.ts.struct(fields) } @@ -243,8 +278,19 @@ const objectSetToTs = ctx => p => { /** @type {(u: UnionSet) => boolean} */ const isTop = u => cmp([{}, u])([{}, top]) === 0 -/** @type {(ctx: _Ctx) => (u: UnionSet) => string} */ -const unionToTs = ctx => u => { +/** + * The absent bit is **masked** before printing: absence is not a value, so + * it contributes no union member — `or(option, number)` prints `number`, + * `option` alone prints `never`, and `or(option, unknown)` prints `unknown` + * — which is the public `Ts<>` of the same node. Where the bit changes what + * a position *prints*, the position asks first: an optional key or trailing + * position strips it by printing through this, and an interior tuple + * position converts it to `undefined` (`interiorToTs`). + * + * @type {(ctx: _Ctx) => (u: UnionSet) => string} + */ +const unionToTs = ctx => u0 => { + const u = withoutUnits(absentBit)(u0) if (isTop(u)) { return 'unknown' } return union([ ...unitToTs(u.unit ?? 0), @@ -295,9 +341,12 @@ export const dataToTs = mut => ([rules, entry]) => { * * Mirrors the compile-time `Ts` mapped type at runtime, in the data * form's canonical order — union members follow its kind order (e.g. - * `option(number)` prints `'undefined|number'`) and structurally different - * but equivalent schemas print identically (`or(true, false)` prints - * `'boolean'`). Pass `true` to emit mutable (non-`readonly`) types. + * `or(number, undefined)` prints `'undefined|number'`) and structurally + * different but equivalent schemas print identically (`or(true, false)` + * prints `'boolean'`). Absence is not a value, so `or(option, number)` + * prints `'number'` — the public `Ts<>` of the same schema; where it lands + * on a declared member, the member prints optional instead. Pass `true` to + * emit mutable (non-`readonly`) types. * * A recursive schema prints as the identifier of its definition — use * {@link dataToTs} to also obtain the `type = ` diff --git a/fjs/rtti/ts/proof.f.mjs b/fjs/rtti/ts/proof.f.mjs index a23346d87..a531271fe 100644 --- a/fjs/rtti/ts/proof.f.mjs +++ b/fjs/rtti/ts/proof.f.mjs @@ -7,7 +7,7 @@ */ import { assertEq } from '../../asserts/module.f.mjs' -import { toData, unitBit } from '../data/module.f.mjs' +import { absentBit, toData, unitBit } from '../data/module.f.mjs' import { boolean, number, string, bigint, unknown, array, open, record, or, option, rest, never } from '../module.f.mjs' import { dataToTs, printer } from './module.f.mjs' @@ -24,9 +24,10 @@ import { dataToTs, printer } from './module.f.mjs' // schema `readonly []`, and nothing else here would have caught it. /** @typedef {Assert, readonly (number | bigint)[]>>} _NonFixedLength */ -// `option(t)` is `or(t, undefined)`; these are the schema types it produces. -/** @typedef {Or} _OptionBoolean */ -/** @typedef {Or} _OptionString */ +// `or(option, t)` — a member that may be absent; these are the schema types +// the spelling produces. +/** @typedef {Or} _OptionBoolean */ +/** @typedef {Or} _OptionString */ // A variadic tuple is the shape the `length` guard exists for, and the only // one: its peel *succeeds*, binding the unknown-length prefix to `I`, so @@ -67,14 +68,15 @@ import { dataToTs, printer } from './module.f.mjs' // statement about which values the union admits. /** @typedef {readonly [typeof number, _OptionString]} _BranchA */ /** @typedef {readonly [typeof string, _OptionBoolean, _OptionNumber]} _BranchB */ -/** @typedef {Or} _OptionNumber */ +/** @typedef {Or} _OptionNumber */ /** @typedef {Assert ? false : true>} _UnionKeepsBranchCorrelation */ /** @typedef {Assert ? true : false>} _UnionAdmitsItsOwnBranches */ -/** @typedef {Assert, readonly [number, bigint, (boolean | undefined)?, (string | undefined)?]>>} _OptionalTail */ +/** @typedef {Assert, readonly [number, bigint, boolean?, string?]>>} _OptionalTail */ // Only the *trailing* run: TypeScript forbids a required element after an -// optional one, so an interior position that admits `undefined` stays required. +// optional one, so an interior position that admits absence stays required, +// with `undefined` — what reading a hole gives — in its type. /** @typedef {Assert, readonly [string | undefined, number]>>} _InteriorStaysRequired */ const toTs = printer() @@ -170,20 +172,36 @@ export const proof = { // `Ts<>` gives it, which is what makes that cast sound emptyTuple: () => eq([], 'readonly[]'), tuple: () => eq([12, true], 'readonly[12,true]'), - // a position the array may end before prints optional, as the key it - // is the array counterpart of does + // a position the array may end before prints optional, with the + // absent bit stripped from what it prints — exact under + // `exactOptionalPropertyTypes`, as the key it is the array + // counterpart of is optionalTuplePosition: () => eq( - [number, option(string)], - 'readonly[number,(undefined|string)?]', + [number, or(option, string)], + 'readonly[number,(string)?]', ), allOptionalTuple: () => eq( - [option(number)], - 'readonly[(undefined|number)?]', + [or(option, number)], + 'readonly[(number)?]', ), - // a declared `unknown` key is a key the container has, so it is not - // dropped the way an `open` struct's is + // an interior position admitting absence prints `undefined|T` — what + // reading a hole gives, and the only spelling TypeScript allows + // before a required element — while a present-`undefined` member + // needs no conversion + interiorOption: () => eq( + [or(option, string), number], + 'readonly[undefined|string,number]', + ), + interiorUndefined: () => eq( + [or(string, undefined), number], + 'readonly[undefined|string,number]', + ), + // a declared `unknown` key is a key the container has — and one that + // must be *present*, `unknown` excluding absence — so it prints + // required; "anything, or nothing" is `or(option, unknown)` emptyStruct: () => eq({}, '{}'), - unknownProp: () => eq({ a: unknown }, '{readonly"a"?:unknown}'), + unknownProp: () => eq({ a: unknown }, '{readonly"a":unknown}'), + unknownOrAbsentProp: () => eq({ a: or(option, unknown) }, '{readonly"a"?:unknown}'), struct: () => eq( { a: number, b: string }, '{readonly"a":number,readonly"b":string}', @@ -217,14 +235,26 @@ export const proof = { // a tuple has a rest element, so this printer says exactly what the schema // says — `Ts<>` renders the same tail, for the same reason. open: { - // an unconstrained tuple, or struct, is the whole kind + // an unconstrained tuple, or struct, is the whole kind — a position + // is unconstrained when it may hold anything *or nothing*, while a + // plain `unknown` position requires presence and stays emptyTuple: () => eq(open([]), 'readonly(unknown)[]'), - unconstrainedTuple: () => eq(open([unknown]), 'readonly(unknown)[]'), + unconstrainedTuple: () => eq(open([or(option, unknown)]), 'readonly(unknown)[]'), + requiredUnknownTuple: () => eq( + open([unknown]), + 'readonly[unknown,...readonly(unknown)[]]', + ), tuple: () => eq(open([12, true]), 'readonly[12,true,...readonly(unknown)[]]'), emptyStruct: () => eq(open({}), '{readonly[k in string]?:unknown}'), struct: () => eq(open({ a: number }), '{readonly"a":number}'), - // an unconstrained key *is* dropped once the container is open - unknownProp: () => eq(open({ a: unknown }), '{readonly[k in string]?:unknown}'), + // the declared-member top — anything, or nothing — *is* dropped once + // the container is open, while a plain `unknown` key requires + // presence and survives + unknownProp: () => eq(open({ a: unknown }), '{readonly"a":unknown}'), + unknownOrAbsentProp: () => eq( + open({ a: or(option, unknown) }), + '{readonly[k in string]?:unknown}', + ), // a stated rest prints as the rest element / index signature it is. // The tail admits `undefined` because a hole past the prefix is no // member, so a reader skips it and the index reads `undefined`. @@ -251,8 +281,13 @@ export const proof = { // an array with no admissible element is the empty array, and nothing // past a prefix is what prints as an exact-length tuple arrayOfNever: () => eq(array(never), 'readonly[]'), - // union members follow the canonical kind order, `undefined` first - option: () => eq(option(number), 'undefined|number'), + // absence is not a value, so at the entry it prints as the rest of the + // union — the public `Ts<>` of the same schema — and alone as `never` + option: () => { + eq(or(option, number), 'number') + eq(option, 'never') + eq(or(option, unknown), 'unknown') + }, normalization: { booleanFromConsts: () => eq(or(true, false), 'boolean'), literalAbsorbed: () => eq(or(42, number), 'number'), @@ -261,11 +296,13 @@ export const proof = { canonicalIdentity: () => { assertEq(toTs(or(string, number)), toTs(or(number, string))) }, - // a key admitting `undefined` may be absent — it prints optional - optionalProp: () => eq({ x: option(string) }, '{readonly"x"?:undefined|string}'), + // a key admitting absence prints optional with the bit stripped; one + // admitting a present `undefined` prints required with it in the type + optionalProp: () => eq({ x: or(option, string) }, '{readonly"x"?:string}'), + presentUndefinedProp: () => eq({ x: or(string, undefined) }, '{readonly"x":undefined|string}'), mixedProps: () => eq( - { a: number, b: option(number) }, - '{readonly"a":number,readonly"b"?:undefined|number}'), + { a: number, b: or(option, number) }, + '{readonly"a":number,readonly"b"?:number}'), }, recursion: { selfList: () => { @@ -344,12 +381,30 @@ export const proof = { [[], '{readonly"a":string}&{readonly[k in string]?:string}']) }, optionalByReference: () => { + // the absent bit read through a reference decides optionality, + // and is masked from the rule's own definition + eqData([{ r: { unit: unitBit(null) | absentBit, number: true } }, + { object: [{ props: { p: 'r' } }] }], + [[['r', 'null|number']], '{readonly"p"?:r}']) + // `undefined` as a value no longer makes a key optional eqData([{ r: { unit: unitBit(null) | unitBit(undefined), number: true } }, { object: [{ props: { p: 'r' } }] }], - [[['r', 'null|undefined|number']], '{readonly"p"?:r}']) + [[['r', 'null|undefined|number']], '{readonly"p":r}']) eqData([{ r: { number: true } }, { object: [{ props: { p: 'r' } }] }], [[['r', 'number']], '{readonly"p":r}']) }, + interiorOptionByReference: () => { + // an interior reference carrying the bit prints its identifier + // with `undefined` unioned in front — the alias cannot be + // rewritten, so the hole's reading rides beside it + eqData([{ r: { unit: absentBit, number: true } }, + { array: [{ prefix: ['r', { number: true }] }] }], + [[['r', 'number']], 'readonly[undefined|r,number]']) + // and a trailing reference with the bit prints optional + eqData([{ r: { unit: absentBit, number: true } }, + { array: [{ prefix: [{ number: true }, 'r'] }] }], + [[['r', 'number']], 'readonly[number,(r)?]']) + }, wholeKinds: () => { eqData([{}, { array: true, object: true }], [[], 'readonly(unknown)[]|{readonly[k in string]?:unknown}']) diff --git a/fjs/rtti/ts/types.ts b/fjs/rtti/ts/types.ts index 5ed2be2a1..34d348d77 100644 --- a/fjs/rtti/ts/types.ts +++ b/fjs/rtti/ts/types.ts @@ -11,11 +11,76 @@ */ import type { And, Equal } from '../../types/ts/types.ts' -import type { Tag0, Tag1, Const, Or, Boolean as RttiBoolean, Bigint as RttiBigint, Number as RttiNumber, String as RttiString, Unknown as RttiUnknown, Struct, Tuple, Type, ConstObject } from '../types.ts' +import type { Tag0, Tag1, Const, Or, Boolean as RttiBoolean, Bigint as RttiBigint, Number as RttiNumber, String as RttiString, Unknown as RttiUnknown, Option as RttiOption, Struct, Tuple, Type, ConstObject } from '../types.ts' import type { Assert } from '../../asserts/types.ts' -import type { phantomKey } from '../../types/phantom/types.ts' +import type { Phantom, phantomKey } from '../../types/phantom/types.ts' import type { StringMap } from '../../types/object/types.ts' +declare const absentKey: unique symbol + +/** + * The type-level marker for rtti's `option` — **absence**, the member that + * is not there. A branded, uninhabitable object type rather than `never` + * (which vanishes in a union, taking the information with it) or + * `undefined` (which would make `or(undefined, number)` optional too and + * conflate the very pair `option` exists to separate). + * + * It appears only in {@link _TsRaw} results and in a `Phantom` annotation's + * raw shape; the public {@link Ts} strips it, and every container position + * lowers it for itself — a struct key or trailing tuple position renders + * optional, an interior tuple position renders `undefined` (what reading a + * hole gives), an array or record element excludes it. One caveat is + * inherent: the top absorbs it — `Absent` is assignable to `unknown`, and to + * the `Object` arm of {@link Unknown} — so no subtype query over a rendered + * type can recover it. Whether a member may be absent is therefore asked of + * the *schema*, by {@link _AdmitsAbsence}, never of the rendered union. + */ +export type Absent = { readonly [absentKey]: typeof absentKey } + +/** + * Whether the schema type admits **absence** — the type-level counterpart of + * `admitsAbsence` in `../common/module.f.mjs`, and the predicate + * {@link StructTs} and {@link TupleTs} decide optionality with. Structural + * over the schema: it recurses through `or` — which does no flattening, so + * `or(or(option, number), string)` needs the recursion — and reads a + * `Phantom` annotation's raw shape for its `Absent` member. It is *not* a + * subtype query against the rendered type: neither `Absent extends Ts<…>` + * (false for every member — `Ts` strips the marker) nor + * `Absent extends _TsRaw<…>` (true at `unknown`, whose top absorbs the + * marker) can answer it — `{ a: unknown }`, which rejects `{}`, would render + * indistinguishably from `{ a: or(option, unknown) }`, which accepts it. + */ +export type _AdmitsAbsence = + unknown extends T ? false : + true extends _AdmitsAbsence1 ? true : false + +type _AdmitsAbsence1 = + T extends { readonly [phantomKey]?: infer O } ? ([Extract] extends [never] ? false : true) : + T extends () => infer I + ? I extends readonly['option'] ? true + : I extends readonly['or', ...infer A extends readonly Type[]] ? _AdmitsAbsence1 + : false + : false + +/** + * Whether the schema type denotes the empty *value* set — nothing but + * absence: `option`, unions of nothing but it, and the empty union. The one + * consumer is {@link ArrayTs}'s empty-array case; structural for the same + * reason {@link _AdmitsAbsence} is, and additionally because testing + * `[Ts] extends [never]` would force the element type of a recursive + * array schema eagerly and never terminate. + */ +type _IsAbsentOnly = + unknown extends T ? false : + false extends _IsAbsentOnly1 ? false : true + +type _IsAbsentOnly1 = + T extends () => infer I + ? I extends readonly['option'] ? true + : I extends readonly['or', ...infer A extends readonly Type[]] ? _IsAbsentOnly1 + : false + : false + /** * The set of primitive literal types representable as rtti `Const` values. * Defined here rather than imported from `djs` to keep rtti free of djs dependencies @@ -47,13 +112,14 @@ export type Array = readonly Unknown[] /** A read-only record of {@link Unknown} values. */ export type Object = { readonly[k in string]?: Unknown } -/** Maps a `Tag0` to its TypeScript type. */ +/** Maps a `Tag0` to its TypeScript type — `option` to the raw {@link Absent} marker. */ export type Info0Ts = T extends 'boolean' ? boolean : T extends 'number' ? number : T extends 'string' ? string : T extends 'bigint' ? bigint : T extends 'unknown' ? Unknown : + T extends 'option' ? Absent : never /** Maps a `Const` schema to its TypeScript type. */ @@ -68,33 +134,53 @@ export type Info1Ts = K extends 'record' ? RecordTs : never -/** Maps an array schema `T` to `readonly Ts[]`. */ -export type ArrayTs = ReadonlyArray> +/** + * Maps an array schema `T` to `readonly Ts[]` — the element excludes + * {@link Absent}, the type-level counterpart of "a rest never sees it" — + * except that an element set with no *present* value at all is the empty + * array, `readonly []`. `readonly never[]` is not that set: + * `new Array(1)` is assignable to it and its `length` is `number`, + * while `array(option)` (and `array(or())`) accept only `[]` at runtime. + * The emptiness test is structural ({@link _IsAbsentOnly}) so a recursive + * element schema stays lazy. + */ +export type ArrayTs = + _IsAbsentOnly extends true ? readonly [] : ReadonlyArray> -/** Maps a record schema `T` to `{ readonly[K in string]?: Ts }`. */ +/** + * Maps a record schema `T` to `{ readonly[K in string]?: Ts }`. The value + * excludes {@link Absent} through `Ts`; no empty-set counterpart of + * {@link ArrayTs}'s is needed — `Record` already admits `{}` + * and nothing else, an object type carrying no length to disagree about. + */ export type RecordTs = { readonly[K in string]?: Ts } /** * Maps a tuple schema to a readonly tuple of resolved types, with the - * **trailing** positions whose sets admit `undefined` rendered optional: - * `[number, bigint, option(boolean), option(string)]` becomes - * `readonly[number, bigint, (boolean|undefined)?, (string|undefined)?]`. + * **trailing** positions whose sets admit absence rendered optional: + * `[number, bigint, or(option, boolean), or(option, string)]` becomes + * `readonly[number, bigint, boolean?, string?]`. * * That is the same rule {@link StructTs} applies per key — a member is - * required exactly when its set excludes `undefined` — so an array may stop - * at the last required position, which is what `../parse/module.f.mjs` and - * `../validate/module.f.mjs` accept. Only the trailing run: TypeScript - * forbids a required element after an optional one, so a position that admits - * `undefined` with a required one after it stays required with `undefined` in - * its type (see {@link _tupleInteriorOption}). + * required exactly when its set excludes **absence**, decided by + * {@link _AdmitsAbsence} over the schema — so an array may stop at the last + * required position, which is what `../parse/module.f.mjs` and + * `../validate/module.f.mjs` accept. Under `exactOptionalPropertyTypes` + * (which this repository sets) the optional rendering is *exact*: + * `readonly [1, number?]` rejects `[1, undefined]`, exactly as the readers + * reject a present `undefined` under `or(option, number)`. Only the trailing + * run renders optional: TypeScript forbids a required element after an + * optional one, so an *interior* position that admits absence renders + * `T | undefined` instead — `undefined` being what reading a hole gives — + * see {@link _tupleInteriorOption}. * * **Deriving this generically took three specific moves**, each defeating an * error that sank the obvious spellings — do not simplify it back: * - * - `MappedTs` resolves `Ts<>` **once per position**, and the split then walks - * the mapped tuple rather than the schema. Testing `undefined extends - * Ts` during the walk evaluates `Ts<>` twice per position and raises - * TS2589 (excessively deep). + * - `MappedTs` resolves `Ts<>` **once per position**, and the split then + * walks the schema with the structural {@link _AdmitsAbsence} while + * carrying the mapped tuple beside it. Evaluating `Ts<>` again during the + * walk raises TS2589 (excessively deep). * - `Extract<…, readonly unknown[]>` is what makes a mapped type spreadable. * Spreading it directly raises TS2574 ("a rest element type must be an array * type") — TypeScript cannot prove a mapped type over a generic `keyof T` is @@ -118,66 +204,70 @@ export type RecordTs = { readonly[K in string]?: Ts } */ type MappedTs = Extract<{ readonly[K in keyof T]: Ts }, readonly unknown[]> -type RequiredPart = - M extends readonly [...infer I extends readonly unknown[], infer L] - ? undefined extends L ? RequiredPart : M - // `M`, not `readonly []`. The peel needs a *required* last element, so - // a tuple whose last element is already optional does not match it — - // and neither does the empty tuple, where the two coincide. Both keep - // the mapping: an optional position is what this transform produces, - // so one the caller wrote is already in the target form. - // - // Keeping the whole mapping does mean a position *before* the caller's - // optional one is not optionalized even where TypeScript could spell - // it: `[N, option(B), (S)?]` renders `readonly [number, boolean | - // undefined, string?]`, not `(boolean | undefined)?`. That is what the - // homomorphic mapping has always rendered for such a schema, so this - // preserves the behaviour rather than introducing it. - : M - -type OmittablePart = - M extends readonly [...infer I extends readonly unknown[], infer L] - ? undefined extends L ? OmittablePart : Acc - : Acc - type AsOptional = Extract<{ readonly[K in keyof O]+?: O[K] }, readonly unknown[]> -export type TupleTs = - // readonly[...{ readonly[K in keyof T]: Ts }, ...readonly Unknown[]] - MappedTs extends infer M extends readonly unknown[] ? SplitTs : never - /** - * Splits one mapped tuple. `M` is naked in the first conditional on purpose: - * that distributes over a union of tuples, so each member is split and rebuilt - * whole. Splitting the union instead lets `RequiredPart` and `OmittablePart` - * distribute separately, and the spread then recombines every prefix with - * every suffix — a union of `[number, option(string)]` and - * `[string, option(boolean), option(number)]` would admit `[number, boolean]`. + * `T` is naked in the first conditional on purpose: that distributes over a + * union of tuple schemas, so each member is mapped and split whole, with its + * own prefix beside its own suffix. Splitting the mapped union instead lets + * the two halves distribute separately, and the spread then recombines every + * prefix with every suffix — a union of `[number, or(option, string)]` and + * `[string, or(option, boolean), or(option, number)]` would admit + * `[number, boolean]`. * - * Splitting a trailing run off also needs a *fixed* length. A schema array of - * non-fixed length (what `.map()` produces) and a variadic tuple - * (`[...(typeof number)[], option(string)]`) both have `length: number` and no - * last position to peel, so they keep the mapping as it is — splitting them - * would drop the element type and the prefix's shape respectively, and widen - * what `Ts` admits. - */ -type SplitTs = - M extends readonly unknown[] - ? number extends M['length'] - ? M - : RequiredPart extends infer R extends readonly unknown[] - ? OmittablePart extends infer O extends readonly unknown[] - ? readonly [...R, ...AsOptional] - : never - : never + * Splitting a trailing run off also needs a *fixed* length. A schema array + * of non-fixed length (what `.map()` produces) and a variadic tuple + * (`[...(typeof number)[], or(option, string)]`) both have `length: number` + * and no last position to peel, so they keep the mapping as it is — + * splitting them would drop the element type and the prefix's shape + * respectively, and widen what `Ts` admits. + */ +export type TupleTs = + T extends Tuple + ? MappedTs extends infer M extends readonly unknown[] + ? number extends M['length'] ? M : _SplitTs + : never : never +/** + * Peels the trailing absence-admitting run off the schema `T` and the mapped + * tuple `M` in parallel — the schema answers *whether* a position may be + * absent, the mapping supplies its rendered type — then rebuilds: the + * required part with interior absence lowered to `| undefined` + * ({@link _InteriorTs}), the peeled run optional. The peel needs a + * *required* last schema element, so a tuple whose last element is already + * optional does not match it — and neither does the empty tuple, where the + * two coincide. Both keep the mapping: an optional position is what this + * transform produces, so one the caller wrote is already in the target form. + */ +type _SplitTs = + T extends readonly [...infer TI extends readonly Type[], infer TL extends Type] + ? _AdmitsAbsence extends true + ? M extends readonly [...infer MI extends readonly unknown[], infer ML] + ? _SplitTs + : readonly [..._InteriorTs, ...AsOptional] + : readonly [..._InteriorTs, ...AsOptional] + : readonly [..._InteriorTs, ...AsOptional] + +/** + * The required part with each **interior** absence-admitting position + * lowered per position: {@link Absent} was already excluded by the mapping's + * `Ts`, and `undefined` — what reading a hole gives, and the only spelling + * TypeScript allows before a required element — is put in its place. A + * position whose schema excludes absence is carried as mapped. + */ +type _InteriorTs = + Extract<{ + readonly[K in keyof M]: + K extends keyof T ? (_AdmitsAbsence extends true ? M[K] | undefined : M[K]) : M[K] + }, readonly unknown[]> + type OptionalFields = { - readonly[K in keyof T as undefined extends Ts ? K : never]?: Ts + readonly[K in keyof T as _AdmitsAbsence extends true ? K : never]?: Ts } type RequiredFields = { - readonly[K in keyof T as undefined extends Ts ? never : K]: Ts + readonly[K in keyof T as _AdmitsAbsence extends true ? never : K]: Ts } /** @@ -240,7 +330,14 @@ type TupleRestTs = ? readonly [...M, ...ReadonlyArray | undefined>] : never -/** Maps a struct schema to a readonly object of resolved types, with optional fields for schemas that include `undefined`. */ +/** + * Maps a struct schema to a readonly object of resolved types, with a key + * rendered optional exactly when its schema admits **absence** + * ({@link _AdmitsAbsence}) — `or(option, t)` is `readonly k?: Ts`, while + * `or(t, undefined)` stays required with `undefined` in its type. Under + * `exactOptionalPropertyTypes` the two are distinct in TypeScript, so the + * rendering is exact where the old `undefined`-keyed one conflated them. + */ export type StructTs = (keyof OptionalFields extends never ? unknown : OptionalFields) & (keyof RequiredFields extends never ? unknown : RequiredFields) @@ -282,6 +379,18 @@ export type StructTs = * type _Check = Assert> * ``` * + * **A schema whose root admits absence needs one more assert.** The + * annotation is {@link _TsRaw}-shaped, so when the wrapped schema's root is + * `or(option, …)` it must carry the {@link Absent} marker — + * `Phantom` — or the member it is used at + * renders required. The pair above cannot catch the omission: both compare + * through the public `Ts`, which strips `Absent` from both sides. Pin the + * raw half with {@link CheckRaw}: + * + * ```ts + * type _CheckRaw = Assert> + * ``` + * * See `fjs/edag/module.f.mjs` (`_exp`/`exp`) for this in practice. Note also * that the phantom branch below does `Exclude`, so a `MyType` * that includes bare `undefined` at its top level will never satisfy @@ -306,8 +415,10 @@ export type Ts = // and hitting TS2589 (type instantiation excessively deep). unknown extends T ? Unknown : // Phantom output: if the schema carries a phantomKey annotation (via WithOut), return - // it directly — one indexed-access, no structural walk, no TS2589 for recursive schemas. - T extends { readonly [phantomKey]?: infer O } ? Exclude : + // it directly — one indexed-access, no structural walk, no TS2589 for recursive + // schemas. The annotation is `_TsRaw`-shaped, so the `Absent` marker is stripped + // here alongside the optional-field `undefined` artifact. + T extends { readonly [phantomKey]?: infer O } ? Exclude : T extends () => infer I ? ( I extends readonly['const', infer C] ? ConstTs : // Info0 @@ -316,8 +427,12 @@ export type Ts = I extends readonly['string'] ? string : I extends readonly['bigint'] ? bigint : I extends readonly['unknown'] ? Unknown : + // `option` contributes no *value*: at the entry position nothing can be + // absent, so the public rendering is what the rest of the union accepts, + // and `never` vanishes in it. The `Absent`-preserving shape is `_TsRaw`. + I extends readonly['option'] ? never : // Info1 - I extends readonly['array', infer E extends Type] ? readonly Ts[] : + I extends readonly['array', infer E extends Type] ? ArrayTs : I extends readonly['record', infer E extends Type] ? { readonly[k in string]?: Ts } : // Or I extends readonly['or', ...infer A extends readonly Type[]] ? Ts : @@ -328,6 +443,27 @@ export type Ts = ) : ConstTs +/** + * The {@link Absent}-preserving counterpart of {@link Ts}, differing only at + * the **root** of a schema — the one place absence has no container position + * to lower it into: `_TsRaw` is `Absent | number` + * where the public `Ts` is `number`. It walks `or` chains and reads a + * `Phantom` annotation verbatim (minus the optional-field `undefined` + * artifact), and delegates every other form to `Ts` — container positions + * lower the marker for themselves, so below the root the two agree. Its two + * consumers are a `Phantom` annotation's shape and {@link CheckRaw}, which + * pins one. + */ +export type _TsRaw = + unknown extends T ? Unknown : + T extends { readonly [phantomKey]?: infer O } ? Exclude : + T extends () => infer I ? ( + I extends readonly['option'] ? Absent : + I extends readonly['or', ...infer A extends readonly Type[]] ? _TsRaw : + Ts + ) : + Ts + /** * Pins a hand-written TypeScript type `A` against the type an rtti schema `B` * actually derives to — `Assert>` reads as "`A` is `Ts`". @@ -346,6 +482,20 @@ export type Check = Equal> */ export type Check3 = And>, Equal>> +/** + * The **raw** counterpart of {@link Check}: pins `A` against + * {@link _TsRaw}``, the {@link Absent}-preserving shape. This is the + * assert with teeth for a `Phantom` annotation on a schema whose root admits + * absence: {@link Check} and {@link Check3} compare through the public + * {@link Ts}, which strips `Absent` from *both* sides, so they pass even + * when the annotation forgot the marker — and the wrapped member then + * renders required. Spell the annotation `Absent | …` and add + * `Assert>` beside the usual pair; a + * schema whose root excludes absence needs nothing new, `_TsRaw` and `Ts` + * agreeing there. + */ +export type CheckRaw = Equal> + // Fast-path: Ts resolves to Unknown without TS2589 overflow. type _any = Assert> @@ -362,34 +512,86 @@ type _struct = Assert> +/** + * A key that may be **absent** — `or(option, string)` — renders optional + * with the marker stripped, while `or(string, undefined)` is a *required* + * key that may hold `undefined`: under `exactOptionalPropertyTypes` the two + * renderings are distinct in TypeScript exactly as the two schemas are + * distinct at runtime. + */ type _structOption = Assert } +>> +type _structPresentUndefined = Assert } >> +type _structOptionAndUndefined = Assert } +>> + +/** + * The pair no subtype query over the rendered type can tell apart — the top + * absorbs {@link Absent} — and {@link _AdmitsAbsence} over the schema does: + * a key declared `unknown` must be *present*, so the closed `{ a: unknown }` + * rejects `{}`, while `or(option, unknown)` is the declared-member top. + */ +type _structUnknownRequired = Assert> +type _structUnknownOptional = Assert } +>> /** * The tuple counterpart of {@link _structOption}: a trailing position whose - * set admits `undefined` renders **optional**, so an array may stop at the - * last required one — the same rule, on the other kind. + * set admits absence renders **optional**, with the marker stripped, so an + * array may stop at the last required one — the same rule, on the other + * kind. */ type _tupleOption = Assert, Or] + readonly[number, bigint, boolean?, string?], + readonly[RttiNumber, RttiBigint, Or, Or] >> /** * Only the *trailing* run. TypeScript forbids a required element after an - * optional one, so a position that admits `undefined` with a required one - * after it keeps `undefined` in its type and stays required. The runtime rule - * is unchanged — such a position may still be absent, since reading it yields - * `undefined` either way — this is what TypeScript can spell, not a narrower - * set. + * optional one, so a position that admits absence with a required one after + * it renders `T | undefined` — `undefined` is what reading a hole gives, so + * the type is honest, if wider than the set: this is what TypeScript can + * spell, not a narrower rule at runtime. */ type _tupleInteriorOption = Assert, RttiNumber] +>> + +/** + * A present-`undefined` interior position needs no lowering — `undefined` is + * already a member of its set — and stays required. + */ +type _tupleInteriorUndefined = Assert, RttiNumber] >> +/** + * The exactness claim of the two stages, pinned with values: a closed tuple + * with a trailing `or(option, number)` renders `readonly [1, number?]`, and + * under `exactOptionalPropertyTypes` that type and the schema agree on every + * row — `[1]` and `[1, 2]` in, `[1, undefined]` and `[1, 2, 3]` out. + */ +type _tupleExact = Ts]> +type _tupleExactRendering = Assert> +type _tupleExactAdmitsShort = Assert +type _tupleExactAdmitsFull = Assert +type _tupleExactRejectsPresentUndefined = Assert +type _tupleExactRejectsLong = Assert + type _const = Assert readonly['const', 12]>> type _boolean = Assert readonly['boolean']>> @@ -449,8 +651,8 @@ type _restOpen = Assert readonly['rest', readonly[RttiNumber, Or], RttiBoolean]>> + readonly[number, string?, ...readonly (boolean | undefined)[]], + () => readonly['rest', readonly[RttiNumber, Or], RttiBoolean]>> /** * A rest with no prefix is the uniform array, and renders the tail rather than @@ -475,3 +677,48 @@ type _restStruct = Assert readonly['rest', readonly[12], readonly[Or]]>> + +/** + * The top-level `option` and its degenerate unions. At the entry position no + * value can be absent, so the public rendering is what the rest of the union + * accepts — `option` alone is `never` — while {@link _TsRaw} keeps the + * marker, which is what {@link CheckRaw} pins. + */ +type _optionAlone = Assert> +type _optionUnion = Assert>> +type _optionUnionRaw = Assert>> + +/** + * The type-level counterpart of "a rest never sees it": an array or record + * element excludes the marker — and an element set with no present value at + * all is the **empty array**, `readonly []`, not `readonly never[]`, whose + * `length` is `number` and which `new Array(1)` inhabits. + */ +type _arrayOption = Assert readonly['array', Or]>> +type _arrayOptionOnly = Assert readonly['array', RttiOption]>> +type _arrayNever = Assert readonly['array', Or]>> +type _recordOption = Assert readonly['record', Or]>> + +/** `or` does no flattening, so absence is found through nested unions. */ +type _nestedOptionKey = Assert, RttiString]> } +>> + +/** + * A `Phantom` annotation is {@link _TsRaw}-shaped: wrapping a schema whose + * root admits absence, it carries {@link Absent}, which + * {@link _AdmitsAbsence} reads from the annotation and the container + * position lowers — the wrapped member renders optional. {@link CheckRaw} + * is the assert with teeth for the annotation itself: the {@link Check} + * pair passes with or without the marker, both halves stripping it. + */ +type _PhantomOption = Phantom, Absent | number> +type _phantomRaw = Assert>> +type _phantomPublic = Assert> +type _phantomOptionalMember = Assert> diff --git a/fjs/rtti/types.ts b/fjs/rtti/types.ts index c1ae83a44..e250f2185 100644 --- a/fjs/rtti/types.ts +++ b/fjs/rtti/types.ts @@ -16,9 +16,11 @@ * * ## Nullary schemas (no type parameter) * - * `boolean`, `number`, `string`, `bigint`, `unknown` are pre-built `Thunk` values - * that describe primitive types. Each is a `_Type0` — a thunk returning a - * single-element tag tuple. + * `boolean`, `number`, `string`, `bigint`, `unknown`, `option` are pre-built + * `Thunk` values. Each is a `_Type0` — a thunk returning a + * single-element tag tuple. All but `option` describe sets of values; + * `option` denotes **absence**, so `or(option, t)` is a member that may be + * omitted. * * ## Unary schemas (one type parameter) * @@ -92,6 +94,7 @@ export type Type = | readonly['number'] | readonly['string'] | readonly['unknown'] + | readonly['option'] // Info1 | readonly['array', Type] | readonly['record', Type] @@ -132,6 +135,14 @@ export type Bigint = _Type0<'bigint'> /** Schema type for any DJS value (`Primitive | UnknownRecord | UnknownArray`). */ export type Unknown = _Type0<'unknown'> +/** + * Schema type for `option` — the nullary schema denoting **absence**, the + * member that is not there. A member that may be omitted is a union with it: + * `or(option, t)`. `unknown` excludes it — absence is not a DJS value — so + * the top of a declared member is `or(option, unknown)`. + */ +export type Option = _Type0<'option'> + /** Tags for unary (one-parameter) type schemas. */ export type Tag1 = 'array' | 'record' diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index 3914aa5dd..ae482ec7f 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -15,8 +15,8 @@ * object it passed in — same reference, same members, same serialization: * * ```js - * const schema = { a: number, b: option(string) } - * parse(schema)({ a: 1, extra: 'x' }) // ['ok', { a: 1, b: undefined }] + * const schema = open({ a: number, b: or(option, string) }) + * parse(schema)({ a: 1, extra: 'x' }) // ['ok', { a: 1 }] * validate(schema)({ a: 1, extra: 'x' }) // ['ok', { a: 1, extra: 'x' }] * ``` * @@ -36,9 +36,9 @@ * the member check alone. * * Closedness is about *undeclared* members and leaves the required/optional - * rule alone: an absent member reads as `undefined`, so a member is required - * exactly when its set excludes `undefined`, and a schema whose trailing - * position admits it still accepts a shorter array. A tuple schema declares by + * rule alone: a member is required exactly when its set excludes **absence** + * — the `option` bit of its union — so a schema whose trailing position says + * `or(option, t)` still accepts a shorter array. A tuple schema declares by * length, so a hole in the *schema* is a position whose schema is `undefined` * — see "A hole is a declared position" in `../README.md`. * @@ -84,6 +84,7 @@ import { ok } from '../../types/result/module.f.mjs' import { + absentMember, constPrimitiveValidate, eachEntry, isArray, @@ -173,6 +174,16 @@ const recordValidate = containerValidate(isObject, () => () => true) * a member on both, but an array is also *as long as it is*: a hole past the * prefix is no member and would slip through the member check alone, so the * array kind answers with its length as well. + * + * A declared member is **absent** when its key or index is neither an own + * property nor an inherited one — HasProperty, since `getItem` reads through + * the prototype, so a member the prototype supplies is still held to what + * the schema says a present value is. Absence is decided here, before + * dispatch: the recursive reader is handed only the value read, and an + * absent key reads `undefined`, so it cannot tell `{}` from + * `{ a: undefined }`. An absent member is legal exactly when its schema + * admits absence (`admitsAbsence` in `../common/module.f.mjs`); a present + * one is dispatched as before. */ const constContainerValidate = /** @@ -196,7 +207,9 @@ const constContainerValidate = } const r = eachEntry( rttiEntries, - (k, v) => /** @type {any} */ (validate(v))(getItem(value, k)), + (k, v) => k in value + ? /** @type {any} */ (validate(v))(getItem(value, k)) + : absentMember(v), undefined, noAccumulate, ) @@ -257,7 +270,9 @@ const restContainerValidate = } const d = eachEntry( rttiEntries, - (k, v) => /** @type {any} */ (validate(v))(getItem(value, k)), + (k, v) => k in value + ? /** @type {any} */ (validate(v))(getItem(value, k)) + : absentMember(v), undefined, noAccumulate, ) @@ -311,6 +326,11 @@ const validateVisitor = /** @type {any} */ ({ constPrimitive: constPrimitiveValidate, primitive0: primitive0Validate, unknown: () => ok, + // Absence is decided by the container loop before dispatch, so a value + // that reaches this handler is present — and no present value is absent. + // An ordinary error is what lets `orVisit` try the other members of + // `or(option, t)`. + option: () => () => verror('unexpected value'), }) /** @@ -338,7 +358,7 @@ const validateVisitor = /** @type {any} */ ({ * validate({ a: number })({ a: 1, b: 2 }) // ['error', …] * * // an absent optional member stays absent - * validate({ a: number, b: option(string) })({ a: 1 }) // ['ok', { a: 1 }] + * validate({ a: number, b: or(option, string) })({ a: 1 }) // ['ok', { a: 1 }] * * // a stated rest says what the undeclared members may be; `open` says anything * validate(rest({ a: number }, number))({ a: 1, b: 2 }) // ['ok', { a: 1, b: 2 }] diff --git a/fjs/rtti/validate/proof.f.mjs b/fjs/rtti/validate/proof.f.mjs index 4d773bf23..bb8d9ba42 100644 --- a/fjs/rtti/validate/proof.f.mjs +++ b/fjs/rtti/validate/proof.f.mjs @@ -128,9 +128,16 @@ const rows = [ [{ a: /** @type {const} */ (42) }, { a: 42, b: 'x' }], [{ a: /** @type {const} */ (42) }, { a: 42 }], // a key declared `unknown` is a member the schema has, so the canonical - // form must not drop it the way an `open` struct's is dropped + // form must not drop it the way an `open` struct's is dropped — and one + // that must be *present*, `unknown` excluding absence [{ a: unknown }, { a: 1 }], [{ a: unknown }, { a: 1, b: 2 }], + [{ a: unknown }, {}], + // the declared-member top — anything, or nothing — is still closed over + // its undeclared keys + [{ a: or(option, unknown) }, {}], + [{ a: or(option, unknown) }, { a: 1 }], + [{ a: or(option, unknown) }, { a: 1, b: 2 }], // and the same rows under `open`, which is the form that admits them [open([/** @type {const} */ (42)]), [42, 'extra']], [open({ a: /** @type {const} */ (42) }), { a: 42, b: 'x' }], @@ -138,16 +145,16 @@ const rows = [ [open({}), { a: 1 }], // closedness is about *undeclared* members and leaves the short-array rule // alone - [[number, option(string)], [42]], + [[number, or(option, string)], [42]], // the rule is per position, not "the last one": every trailing position // whose set admits `undefined` may be absent, so an array may stop at the // last required one - [[number, bigint, option(string), option(null)], [2, 4n]], - [[number, bigint, option(string), option(null)], [2, 4n, 'x']], - [[number, bigint, option(string), option(null)], [2, 4n, 'x', null]], - [[number, bigint, option(string), option(null)], [2]], - [[number, bigint, option(string), option(null)], [2, 4n, 5]], - [{ a: number, b: option(string) }, { a: 1 }], + [[number, bigint, or(option, string), or(option, null)], [2, 4n]], + [[number, bigint, or(option, string), or(option, null)], [2, 4n, 'x']], + [[number, bigint, or(option, string), or(option, null)], [2, 4n, 'x', null]], + [[number, bigint, or(option, string), or(option, null)], [2]], + [[number, bigint, or(option, string), or(option, null)], [2, 4n, 5]], + [{ a: number, b: or(option, string) }, { a: 1 }], [{ a: number }, { a: 'one' }], // a hole in a tuple schema is a declared position whose schema is // `undefined`, so the schema's length is what it declares — the reading @@ -195,8 +202,8 @@ const rows = [ [rest([selfList0], [selfList1, never]), [undefined, ,]], [or(number, string), true], [or(number, string), 'hello'], - [option(number), undefined], - [option(number), null], + [or(option, number), undefined], + [or(option, number), null], [{ user: { name: string, age: number } }, { user: { name: 'A', age: 'old' } }], ] @@ -210,15 +217,15 @@ export const proof = { // different document. `validate` answers the same question about the // value it was handed and hands it back. verbatim: { - // An absent optional member stays absent. `'b' in out` is the - // assertion, not `out.b === undefined`: `parse` satisfies the latter. + // An absent optional member stays absent — on both readers, absence + // being a member of the set rather than a spelling of `undefined`: + // `parse` omits it from what it builds instead of materializing it. absentOptionalStaysAbsent: () => { - const schema = { a: number, b: option(string) } + const schema = { a: number, b: or(option, string) } const input = { a: 1 } const out = unwrap(validate(schema)(input)) assert(!('b' in out), 'an absent optional member must stay absent') - // The contrast that motivates the module. - assert('b' in unwrap(parse(schema)(input)), 'parse materializes it') + assert(!('b' in unwrap(parse(schema)(input))), 'parse omits it too') }, // An undeclared member survives — where the schema admits one at all. // `parse` accepts the same values and does not carry the member into @@ -301,7 +308,7 @@ export const proof = { // identically. The one case where opening does change the answer is at the // end. optionalPositions: () => { - const t = /** @type {const} */ ([number, bigint, option(string), option(null)]) + const t = /** @type {const} */ ([number, bigint, or(option, string), or(option, null)]) /** @type {(rtti: Type) => (check: (r: readonly [string, unknown]) => void) => (value: Unknown) => void} */ const every = rtti => check => @@ -314,16 +321,17 @@ export const proof = { accepted([2, 4n]) // stops at the last required position accepted([2, 4n, 'x']) // the first optional present accepted([2, 4n, 'x', null]) // both present - // Omission is independent, not just truncation: an absent member - // reads as `undefined` wherever it sits, so position 2 may be - // missing while position 3 is present. A hole and an explicit - // `undefined` are the same value, so both spellings are accepted. + // Omission is independent, not just truncation: a member is + // absent wherever its index is missing, so position 2 may be + // missing while position 3 is present. accepted([2, 4n, , null]) //< a hole at position 2 - accepted([2, 4n, undefined, null]) //< the same value, spelled densely - rejected([2]) // `bigint` excludes `undefined` + // A present `undefined` is a value, not a spelling of absence: + // `or(option, string)` admits the hole above and rejects this. + rejected([2, 4n, undefined, null]) + rejected([2]) // `bigint` excludes absence rejected([2, 4n, 5]) // an optional that is present is still checked - // The mirror of the two rows above: `bigint` excludes `undefined`, - // so omitting position 1 fails however much of the rest is present. + // The mirror of the rows above: `bigint` excludes absence, so + // omitting position 1 fails however much of the rest is present. rejected([2, , 'x', null]) //< a hole at position 1 } // What opening does change: an element past the declared positions is @@ -345,7 +353,7 @@ export const proof = { // positional, not a shift — `[, 5]` holds `5` at position 1 and is // accepted, while `[5]` holds it at position 0 and is not. interiorOptionBeforeRequired: () => { - const t = /** @type {const} */ ([option(string), number]) + const t = /** @type {const} */ ([or(option, string), number]) /** @type {(rtti: Type) => (check: (r: readonly [string, unknown]) => void) => (value: Unknown) => void} */ const every = rtti => check => @@ -357,7 +365,9 @@ export const proof = { // the shapes the trailing-option cases there already put through it. for (const rtti of [t, open(t)]) { every(rtti)(assertOk)([, 5]) //< a hole at position 0 - every(rtti)(assertOk)([undefined, 5]) //< the same value, spelled densely + // `[undefined, 5]` is a *different value*: present-`undefined` at + // position 0, which `or(option, string)` rejects. + every(rtti)(assertError)([undefined, 5]) every(rtti)(assertOk)(['x', 5]) every(rtti)(assertError)([5]) //< `number` at position 1 is required } @@ -520,7 +530,7 @@ export const proof = { // filled in, so the array keeps its length. shortArrayKeepsItsLength: () => { const short = [42] - const out = unwrap(validate([number, option(string)])(short)) + const out = unwrap(validate([number, or(option, string)])(short)) assert(Object.is(out, short), 'expected the original array') assertEq(short.length, 1, 'no gap is filled') }, @@ -627,17 +637,76 @@ export const proof = { }, }, option: { + // At the entry position nothing can be absent, so `or(option, t)` + // accepts exactly what `t` accepts — a present `undefined` included + // in the rejects, unless the union carries it as a value. ok: () => { - const t = option(number) + const t = or(option, number) assertOk(validate(t)(42)) - assertOk(validate(t)(undefined)) + assertOk(validate(or(option, number, undefined))(undefined)) }, error: () => { - const t = option(number) + const t = or(option, number) + assertError(validate(t)(undefined)) assertError(validate(t)(null)) assertError(validate(t)('42')) + // and `option` alone accepts nothing at all + assertError(validate(option)(undefined)) + assertError(validate(option)(42)) }, }, + // Absence became describable: `{}` and `{ a: undefined }` are two + // distinct values, and every pair of the three spellings separates them + // as stage 2 states — `or(option, t)` admits omission only, + // `or(t, undefined)` a present `undefined` only, and the union of all + // three admits both. + absenceIsNotUndefined: () => { + for (const read of [v, p, d]) { + const omittable = read({ a: or(option, number) }) + assertOk(omittable({})) + assertOk(omittable({ a: 1 })) + assertError(omittable({ a: undefined })) + const present = read({ a: or(number, undefined) }) + assertError(present({})) + assertOk(present({ a: undefined })) + const both = read({ a: or(option, number, undefined) }) + assertOk(both({})) + assertOk(both({ a: undefined })) + } + }, + // A negative field: `{ a: option }` is "objects with no `a`" — a set the + // old design could not express at a declared key. + negativeField: () => { + for (const read of [v, p, d]) { + const noA = read(open({ a: option })) + assertOk(noA({})) + assertOk(noA({ b: 1 })) + assertError(noA({ a: 1 })) + assertError(noA({ a: undefined })) + } + }, + // `admitsAbsence` traverses nested unions — the schema-form `or` does no + // flattening, so `or(or(option, number), string)` has no `option` among + // its direct members — and carries a visited set, so a recursive union + // that reaches itself before `option` still terminates. + admitsAbsenceTraversal: () => { + for (const read of [v, p]) { + const nested = read({ a: or(or(option, number), string) }) + assertOk(nested({})) + assertOk(nested({ a: 1 })) + assertOk(nested({ a: 'x' })) + assertError(nested({ a: true })) + } + // The visited set is what terminates this: the cycle reaches itself + // before it reaches `option`. Only the absent path is asked — a pure + // `or` cycle never terminates on a *present* value in the thunk + // readers, the standing limitation `../data/proof.f.mjs` records. + /** @typedef {() => readonly ['or', _Cycle, typeof option]} _Cycle */ + /** @type {_Cycle} */ + const cycle = () => ['or', cycle, option] + assertOk(v({ a: cycle })({})) + assertOk(p({ a: cycle })({})) + }, path: { rootMismatch: () => assertErrorPath([])(validate(number)('not a number')), arrayIndex: () => assertErrorPath(['1'])(validate(array(number))([1, 'two', 3])), @@ -701,7 +770,7 @@ export const proof = { // An absent optional member still stays absent — a container's rest // says nothing about a member it declares. absentOptionalStaysAbsent: () => { - const out = unwrap(validate({ a: number, b: option(string) })({ a: 1 })) + const out = unwrap(validate({ a: number, b: or(option, string) })({ a: 1 })) assert(!('b' in out), 'an absent optional member must stay absent') }, path: () => { @@ -767,11 +836,11 @@ export const proof = { // a rest with nothing present past the prefix admits it. lengthDoesNotBoundTheWalk: () => { const big = new Array(2 ** 32 - 1) - assertError(v([option(string)])(big)) + assertError(v([or(option, string)])(big)) assertOk(v(rest([], string))(big)) }, arrayOptional: () => { - const a = /** @type {const} */([number, option(string)]) + const a = /** @type {const} */([number, or(option, string)]) const v = validate(a) assertOk(v([5])) assertError(v(["n"])) diff --git a/fjs/types/phantom/types.ts b/fjs/types/phantom/types.ts index 944993881..8028eacda 100644 --- a/fjs/types/phantom/types.ts +++ b/fjs/types/phantom/types.ts @@ -37,6 +37,21 @@ export type { phantomKey } * type _Check = Assert> * ``` * + * For an rtti schema the annotation is **`_TsRaw`-shaped**: when the wrapped + * schema's *root* admits absence — `or(option, …)` — `T` must carry the + * `Absent` marker (`Absent | MyType`), or a member the wrapped schema is + * used at silently renders required. The pair above cannot catch the + * omission, both halves comparing through the public `Ts<>`, which strips + * `Absent` from both sides — so such a schema **requires** the raw assert + * beside them, with `CheckRaw` and `Absent` from `fjs/rtti/ts/types.ts`: + * + * ```ts + * type _CheckRaw = Assert> + * ``` + * + * A schema whose root excludes absence needs nothing new — `_TsRaw` and + * `Ts` agree everywhere below a root `or` chain. + * * One phantom per recursive cycle is enough: `fjs/edag` wraps only `exp`, * the union every node kind recurses through, and the node schemas * themselves stay un-phantomed, each pinned with a plain `Check`. diff --git a/todo/rtti-type-system.md b/todo/rtti-type-system.md index bd579ee28..06146a7f4 100644 --- a/todo/rtti-type-system.md +++ b/todo/rtti-type-system.md @@ -139,7 +139,7 @@ import { array, number, option, or, string } from 'functionalscript/fjs/rtti/mod const key = or(number, string) const keys = array(key) -const maybeKey = option(key) +const maybeKey = or(option, key) //: key export const a = 'hello' @@ -547,7 +547,7 @@ rejected by `validate`. That is the exact disagreement this epic exists to remove, surviving inside its own deliverable. (The *tuple* kind has no such gap: a TypeScript tuple is exact-length, so `Ts<>` renders a closed tuple exactly — which is what stage 1 of -[option-as-omission](../fjs/rtti/todo/option-as-omission.md) settled.) +`option` as omission settled; both stages have landed.) Two things keep this from undermining the whole direction, and both need stating rather than assuming: @@ -911,9 +911,8 @@ are stated instead: the model rather than an approximation of it, and the printer prints the same exact tuple. `open(c)` is what admits a longer array, and both renderers emit the tail that says so. This bullet used to record a live - divergence and no longer does; stage 1 of - [option-as-omission](../fjs/rtti/todo/option-as-omission.md) - removed it. + divergence and no longer does; stage 1 of `option` as omission + (landed, both stages) removed it. **A third disagreement runs the other way, and has narrowed.** `RestTs` ([`ts/types.ts`](../fjs/rtti/ts/types.ts)) now renders a stated From 7ada040d5cb297df86bc9e41aeed69e21c76fefd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:17:15 +0000 Subject: [PATCH 130/370] changelog: name the entry by its pull request, #1748 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/{option-as-omission.md => 1748.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/unreleased/{option-as-omission.md => 1748.md} (100%) diff --git a/changelog/unreleased/option-as-omission.md b/changelog/unreleased/1748.md similarity index 100% rename from changelog/unreleased/option-as-omission.md rename to changelog/unreleased/1748.md From 0d297672f6a859a068a5d9756876550d3a7a0f5b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:24:23 +0000 Subject: [PATCH 131/370] rtti: the tuple rebuild never runs a method of the value, and `Phantom` short-circuits `_IsAbsentOnly` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on #1748: - `parse`'s tuple rebuild called `slice`/`map` on the input, so an accepted `Array` subclass overriding either could hand back a result that fails the schema it was parsed against, or throw past the `Result` API. The rebuild is now built from the parsed entries alone — hole-run segments folded with `concat` on a trusted plain array, which appends a spreadable argument element by present element and so keeps the holes — and the value is never consulted. Pinned in `host.proof.mjs` with an array whose prototype supplies a hostile `slice` and a throwing `map`. - `_IsAbsentOnly` (behind `ArrayTs`) walked a phantom-wrapped schema's thunk, re-expanding a recursive union into itself — TS2589 where the annotation exists to prevent it. It now reads the `Phantom` annotation first, as `_AdmitsAbsence` and `Ts` do: the annotation is `_TsRaw`-shaped, so absent-only is `Exclude` being `never`. Pinned with a recursive `X = or(option, number, X)` wrapped and used as an array element, and an absence-only annotation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- fjs/rtti/host.proof.mjs | 27 ++++++++++--- fjs/rtti/parse/module.f.mjs | 81 +++++++++++++++++++------------------ fjs/rtti/parse/proof.f.mjs | 2 + fjs/rtti/ts/types.ts | 35 ++++++++++++++++ 4 files changed, 100 insertions(+), 45 deletions(-) diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index ef9d538d5..23ccaf6ca 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -182,12 +182,29 @@ export const proof = { assertOk(read({ a: or(option, string) })(value)) } }, + // `parse`'s tuple rebuild never runs a method of the value: it is built + // from the parsed entries alone, on trusted plain arrays. An accepted + // `Array` subclass — or, as here, an array whose prototype supplies the + // methods — can override `slice`/`map`; a rebuild that called them was + // handed `['ok', []]` for an input holding `1`, a result that fails the + // very schema it was parsed against, and a throwing override escaped + // the `Result` API entirely. + hostileArrayMethodsDoNotReachTheRebuild: () => { + const value = [1] + Object.setPrototypeOf(value, Object.assign([], { + slice: () => [], + map: () => { throw 'hostile' }, + })) + const r = p([number])(value) + assert(r[0] === 'ok', 'expected ok') + assertStructurallySame(/** @type {readonly unknown[]} */ (r[1]), [1]) + }, // …and `parse` **materializes** the inherited value as an own member of - // what it builds: against a prototype-supplied index no immutable builder - // can produce the hole — `slice` and `.map` copy by HasProperty, an - // `Object.hasOwn` guard inside a `.map` callback cannot stop the own - // output element from existing, and a fresh `Array(n)` inherits the index - // too — so this is a pinned, bounded divergence, unreachable from + // what it builds: the member is *present* — HasProperty is what the + // check dispatched on — so its parsed value is in the entries the + // rebuild is made of, and the output carries what was checked rather + // than a hole at an index the input answered for. A pinned, bounded + // divergence from the input's own/inherited split, unreachable from // FunctionalScript (which has neither mutation nor prototype writes). // `validate` is untouched: it returns the value it was given. parseMaterializesAnInheritedIndex: () => { diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index c16e22e9b..092a91355 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -86,44 +86,45 @@ const arrayRebuild = entries => entries.map(([, v]) => v) const recordRebuild = entries => Object.fromEntries(entries) /** - * Rebuilds a **const** container from the declared members that were - * present, keyed by the same HasProperty test the check dispatched on — - * one per kind, since only the array kind has holes to preserve. + * The **tuple** kind's rebuild over its declared members — only the present + * ones reach `entries` (`recordRebuild` is the struct kind's counterpart, + * where dropping an absent key needs nothing more): the present members at + * their own indices, holes at the absent ones before them, ending at the + * last present position — so a trailing absent run shortens the result and + * an interior hole survives (materializing it as `undefined` would denote a + * different value, and omitting it would shift every position after it). * - * @template C - * @typedef {(value: C, entries: ReadonlyArray) => Unknown} _RebuildDeclared - */ - -/** - * The array kind's rebuild is **slice, then map**: truncate the value to the - * last *present* declared position, then map each present index to its - * parsed result. Mapping alone is not enough — `.map` preserves length, so a - * trailing absent run would survive as a sparse tail and serialize back to - * the `null`s this stage removes — and omitting absent entries from a - * rebuilt list would shift every position after an interior hole. - * `slice` and `.map` both skip a hole, so an interior one survives as a - * hole; both use HasProperty, so an index the value only *inherits* is - * materialized as an own property of the result, carrying its parsed value. - * That divergence is bounded and pinned rather than closed: no immutable - * builder can produce the hole against a prototype-supplied index — - * `.map` creates the own output element whatever the callback returns, and - * a fresh `Array(n)` inherits the index too — and the escapes - * (`Object.assign`, index assignment) are mutation, which FunctionalScript - * forbids. See `../host.proof.mjs`. + * The construction is segments — a fresh `new Array(gap)` of holes before + * each present member, then the member — folded with `concat` on a trusted + * empty array, which appends a spreadable argument element by *present* + * element and so keeps the holes. The input value is never consulted, and + * every array touched is a plain one this module made, which is the point: + * an earlier slice-then-map of the input let an accepted `Array` subclass + * override `slice` and hand `parse` a result that fails the very schema it + * was parsed against. An index the value only *inherits* is a present + * member (HasProperty is what the check dispatched on), so it sits in + * `entries` and is materialized as an own member of the result, carrying + * its parsed value — see `../host.proof.mjs`. * - * @type {_RebuildDeclared>} + * @type {_Rebuild} */ -const tupleRebuild = (value, entries) => { - if (entries.length === 0) { return [] } - /** @type {StringMap} */ - const byIndex = Object.fromEntries(entries) - const end = Number(entries[entries.length - 1][0]) + 1 - return value.slice(0, end).map((_, i) => byIndex[i]) +const tupleRebuild = entries => { + /** @type {readonly (readonly Unknown[])[]} */ + let segments = [] + let next = 0 + for (const [k, v] of entries) { + const i = Number(k) + segments = i === next + ? [...segments, [v]] + : [...segments, new Array(i - next), [v]] + next = i + 1 + } + return emptySegment.concat(...segments) } -/** The struct kind drops an absent key: only the present entries are rebuilt. */ -/** @type {_RebuildDeclared>} */ -const structRebuild = (_value, entries) => Object.fromEntries(entries) +/** `tupleRebuild`'s trusted `concat` receiver — a plain array, so its species is `Array`. */ +/** @type {ReadonlyArray} */ +const emptySegment = [] /** `eachEntry`'s accumulator seed: entries are consed on in reverse as they parse. */ /** @type {List} */ @@ -239,7 +240,7 @@ const constContainerParse = * @param {IsContainer} isContainer * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem - * @param {_RebuildDeclared} rebuild + * @param {_Rebuild} rebuild * @param {Fits} fits * @returns {(rtti: T) => Parse} */ @@ -267,7 +268,7 @@ const constContainerParse = ) if (r[0] === 'error') { return r } return undeclaredMembers(declared, value).length === 0 && fits(value, declared.length) - ? /** @type {any} */ (ok(rebuild(value, orderedEntries(r[1])))) + ? /** @type {any} */ (ok(rebuild(orderedEntries(r[1])))) : verror('unexpected value') } } @@ -284,7 +285,7 @@ const structParse = constContainerParse( isObject, structSchemaEntries, (value, k) => value[k], - structRebuild, + recordRebuild, () => true, ) @@ -306,7 +307,7 @@ const restContainerParse = * @param {IsContainer} isContainer * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem - * @param {_RebuildDeclared} rebuild + * @param {_Rebuild} rebuild * @param {(rtti: S, r: Type) => Fits} restFits * @returns {(rtti: S, r: Type) => ValidateE} */ @@ -337,12 +338,12 @@ const restContainerParse = const extra = undeclaredMembers(declared, value) if (extra.length === 0) { return fits(value, declared.length) - ? ok(rebuild(value, orderedEntries(d[1]))) + ? ok(rebuild(orderedEntries(d[1]))) : verror('unexpected value') } const restParse = /** @type {any} */ (parse(r)) const e = eachEntry(extra, (_k, v) => restParse(v), undefined, noAccumulate) - return e[0] === 'error' ? e : ok(rebuild(value, orderedEntries(d[1]))) + return e[0] === 'error' ? e : ok(rebuild(orderedEntries(d[1]))) } } @@ -358,7 +359,7 @@ const restStructParse = restContainerParse( isObject, structSchemaEntries, (value, k) => value[k], - structRebuild, + recordRebuild, () => () => true, ) diff --git a/fjs/rtti/parse/proof.f.mjs b/fjs/rtti/parse/proof.f.mjs index b325d6f29..6a21d58a3 100644 --- a/fjs/rtti/parse/proof.f.mjs +++ b/fjs/rtti/parse/proof.f.mjs @@ -352,6 +352,8 @@ export const proof = { const built = unwrap(parse([number, or(option, number), or(option, number)])([1, , ])) assertEq(built.length, 1, 'the trailing absent run is gone') assertEq(built[0], 1, 'the present prefix survives') + // nothing present at all rebuilds the empty array + assertStructurallySame(unwrap(parse([or(option, number)])([])), []) }, structDropsTheKey: () => { const built = unwrap(parse({ a: number, b: or(option, string) })({ a: 1 })) diff --git a/fjs/rtti/ts/types.ts b/fjs/rtti/ts/types.ts index 34d348d77..7f58ed042 100644 --- a/fjs/rtti/ts/types.ts +++ b/fjs/rtti/ts/types.ts @@ -69,12 +69,22 @@ type _AdmitsAbsence1 = * reason {@link _AdmitsAbsence} is, and additionally because testing * `[Ts] extends [never]` would force the element type of a recursive * array schema eagerly and never terminate. + * + * A `Phantom` annotation is read **before** the thunk walk, exactly as + * {@link _AdmitsAbsence} and `Ts` read it: a phantom-wrapped schema is still + * a thunk, so descending its `or` chain would re-expand the very recursion + * the annotation exists to spare (TS2589). The annotation is `_TsRaw`-shaped, + * so its present part is what survives `Exclude` — + * the same `Exclude` the public `Ts` applies — and "absent-only" is that + * part being `never`. (`undefined` there is the optional-field artifact the + * `Phantom` contract already excludes from annotations, not a value member.) */ type _IsAbsentOnly = unknown extends T ? false : false extends _IsAbsentOnly1 ? false : true type _IsAbsentOnly1 = + T extends { readonly [phantomKey]?: infer O } ? ([Exclude] extends [never] ? true : false) : T extends () => infer I ? I extends readonly['option'] ? true : I extends readonly['or', ...infer A extends readonly Type[]] ? _IsAbsentOnly1 @@ -722,3 +732,28 @@ type _phantomOptionalMember = Assert> + +/** + * The `Phantom` short-circuit holds at every structural predicate, not only + * in `Ts`: a phantom-wrapped schema is still a thunk, so a predicate that + * walked it — {@link _IsAbsentOnly} behind {@link ArrayTs} was the one that + * did — re-expands a recursive union into itself and raises TS2589 where the + * annotation exists precisely to prevent it. Pinned with a recursive + * `X = or(option, number, X)` used as an array element, and with an + * absence-only annotation, the pair that exercises both answers of the + * phantom branch. + */ +type _PhantomRecThunk = () => readonly['or', RttiOption, RttiNumber, _PhantomRecThunk] +type _PhantomRec = Phantom<_PhantomRecThunk, Absent | number> +type _phantomRecursiveArray = Assert readonly['array', _PhantomRec] +>> +type _phantomRecursiveMember = Assert> +type _phantomAbsentOnlyArray = Assert readonly['array', Phantom] +>> From b1bc5aa5eb360c8022107388404889f5a0b989dc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:30:11 +0000 Subject: [PATCH 132/370] emergent_testing: one reporter for both runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6 of todo/share-browser-console-runner.md: the leaf-landed and run-ended events are now one seam. `Reporter.result` receives the shared `TestResult` built by the runner (the raw `SandboxResult` and throw flag still travel with it — describing a thrown value stays each host's part), and `Reporter.summary` receives `RunTotals`, folded from the leaf results by the new shared `addResult`/`zeroTotals`. The `fjs t` summary line, its exit code, and the browser report's counts and pass/fail status all read that same fold; the browser report's own `duration` stays wall-clock, documented on `RunTotals`, because its leaves run concurrently. Printed output, exit codes and the browser wire report are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/emergent_testing/browser.mjs | 39 ++++--- fjs/emergent_testing/module.f.mjs | 100 ++++++++++-------- fjs/emergent_testing/proof.f.mjs | 61 ++++++++--- .../todo/report-before-running.md | 7 +- .../todo/share-browser-console-runner.md | 32 ++++-- fjs/emergent_testing/types.ts | 46 +++++--- 6 files changed, 189 insertions(+), 96 deletions(-) diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 192d3318f..6a7e82f6d 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -19,7 +19,7 @@ * @import { Result } from '../types/result/types.ts' */ -import { collectTests, testResult } from './module.f.mjs' +import { addResult, collectTests, testResult, zeroTotals } from './module.f.mjs' import { error as errorResult, invert, ok } from '../types/result/module.f.mjs' /** @type {(value: unknown) => string} */ @@ -200,13 +200,24 @@ const runOne = (module, path, throws, fn, result) => { return Promise.resolve().then(() => [fn()]).then(([value]) => settled(value), failed) } -/** @type {(status: string, duration: number, results: readonly _BrowserTestResult[]) => BrowserTestReport} */ -const reportOf = (status, duration, results) => { - const failed = results.filter(result => result.status === 'failed').length +/** + * The run-ended event, as the page reports it. The counts — and with them the + * run's own pass/fail status — come from folding the results with the same + * `addResult` that decides `fjs t`'s summary and exit code, so "did the run + * pass" has one answer across the runners. `duration` stays the page's own + * wall clock: leaves run concurrently here, so the fold's summed duration is + * not how long the run took (see `RunTotals`). + * + * `status` overrides the folded decision when the run never got to its leaves + * — module loading failed — which no leaf result can express. + * + * @type {(duration: number, results: readonly _BrowserTestResult[], status?: string) => BrowserTestReport} */ +const reportOf = (duration, results, status = undefined) => { + const { passed, failed } = results.reduce(addResult, zeroTotals) return { - status, + status: status ?? (failed !== 0 ? 'failed' : 'passed'), browser: navigator.userAgent, - totals: { tests: results.length, passed: results.length - failed, failed }, + totals: { tests: results.length, passed, failed }, duration, results, } @@ -215,6 +226,11 @@ const reportOf = (status, duration, results) => { /** * Runs named proof exports and returns the serializable browser report. * + * `result` is the page's subscription to the leaf-landed event — the same + * event `fjs t`'s `Reporter.result` carries, a shared `TestResult` plus the + * browser's own `message`/`stack` part — and the resolved report is its + * run-ended event, with totals folded by the shared `addResult`. + * * @type {(modules: readonly (readonly [string, unknown])[], result?: (result: _BrowserTestResult) => void) => Promise} */ export const runBrowserProofs = (modules, result = () => undefined) => { @@ -259,11 +275,7 @@ export const runBrowserProofs = (modules, result = () => undefined) => { ).then(next => runBatch(index + batchSize, next)) } const completed = runBatch(0, []) - return completed.then(results => reportOf( - results.some(result => result.status === 'failed') ? 'failed' : 'passed', - performance.now() - start, - results, - )) + return completed.then(results => reportOf(performance.now() - start, results)) } /** @typedef {(source: string) => Promise<{ readonly proof?: unknown }>} _BrowserImporter */ @@ -337,11 +349,12 @@ export const startBrowserTestSources = (root, sources, importer) => { // that disagreed with `results` would tell an automated consumer // the suite was empty rather than broken. const duration = performance.now() - start - return publish(root, Promise.resolve(reportOf('infrastructure-error', duration, + return publish(root, Promise.resolve(reportOf(duration, rejected.map(({ source, error }) => { const [message, stack] = errorDetails(error) return moduleFailure(source, duration, message, stack) - })))) + }), + 'infrastructure-error'))) } return startBrowserTests(root, loadedModules.flatMap(module => module.status === 'loaded' diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index 9421738ef..06cef624a 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -3,7 +3,7 @@ * * Two parallel execution paths: * - `runModule` / `Reporter` — self-hosted Effects runner used by `fjs t`; - * sandboxes each leaf call individually and accumulates `TestState`. + * sandboxes each leaf call individually and accumulates `RunTotals`. * - `registerModule` / `TestContext` — registers tests with an external * framework (Node `--test`, Bun, Deno) at import time; the framework owns * scheduling and pass/fail counting. @@ -13,7 +13,7 @@ * @import { Operation } from '../effects/types.ts' * @import { Effect, NotImplemented } from '../effects/types.ts' * @import { LoadModuleOperations, ModuleMap } from '../dev/types.ts' - * @import { TestFn, TestEntry, TestSet, Path, Reporter, TestResult, _TestState, _TestAndPath } from './types.ts' + * @import { TestFn, TestEntry, TestSet, Path, Reporter, RunTotals, TestResult, _TestAndPath } from './types.ts' * @import { All, Await, Env, IoChannel, NodeProgram, NodeProgramOptions, Program, Sandbox, SandboxResult, Test, TestContext, Write, WriteConsoles } from '../effects/node/types.ts' */ @@ -26,13 +26,28 @@ import { loadModuleMap } from '../dev/module.f.mjs' import { invert } from '../types/result/module.f.mjs' import { definedEntries } from '../types/object/module.f.mjs' -/** @type {(delta: number) => (ts: _TestState) => _TestState} */ -const addPass = delta => ts => - ({ ...ts, time: ts.time + delta, pass: ts.pass + 1 }) +/** + * The empty {@link RunTotals}: what a run's totals are before any leaf lands. + * + * @type {RunTotals} + */ +export const zeroTotals = { passed: 0, failed: 0, duration: 0 } -/** @type {(delta: number) => (ts: _TestState) => _TestState} */ -const addFail = delta => ts => - ({ ...ts, time: ts.time + delta, fail: ts.fail + 1 }) +/** + * Folds one leaf-landed event into a run's totals. + * + * This is where "did the run pass" is decided, for every runner: the counts + * come from each result's shared `status`, so the summary line, the exit code + * and the browser report's totals all read the same fold of the same events + * rather than each counting their own way. + * + * @type {(totals: RunTotals, r: TestResult) => RunTotals} + */ +export const addResult = (totals, r) => ({ + passed: totals.passed + (r.status === 'passed' ? 1 : 0), + failed: totals.failed + (r.status === 'failed' ? 1 : 0), + duration: totals.duration + r.duration, +}) /** @type {(a: number) => string} */ const timeFormat = a => { @@ -154,49 +169,49 @@ export const registerModule = (ctx, k, v, star) => { return mapStep(allOk(...tests.map(e => registerOne(ctx, e))), () => undefined) } -/** @type {(a: _TestState, b: _TestState) => _TestState} */ -const mergeState = (a, b) => - ({ time: a.time + b.time, pass: a.pass + b.pass, fail: a.fail + b.fail }) - -/** @type {_TestState} */ -const zero = { time: 0, pass: 0, fail: 0 } +/** @type {(a: RunTotals, b: RunTotals) => RunTotals} */ +const mergeTotals = (a, b) => + ({ passed: a.passed + b.passed, failed: a.failed + b.failed, duration: a.duration + b.duration }) /** * @template {Operation} O * @param {Reporter} reporter - * @returns {(k: string, v: unknown) => (ts: _TestState) => Effect} + * @returns {(k: string, v: unknown) => (ts: RunTotals) => Effect} */ const runModule = ({ result, test }) => (k, v) => ts => { - /** @type {(entry: _TestAndPath) => Effect} */ + /** @type {(entry: _TestAndPath) => Effect} */ const one = ([testPath, set]) => { - // The sandbox result is still needed after it has been reported, so the - // reporting call is captured rather than nested inside its own step. + // The leaf's shared record is built here, next to the sandbox result it + // is read from, so the leaf-landed event carries the value already + // decided — a reporter renders `t`, it does not derive its own. + const evaluated = mapStep( + test(k, testPath, set), + sr => /** @type {const} */ ([testResult(k, testPath, sr), sr])) + // Both are still needed after they have been reported, so the reporting + // call is captured rather than nested inside its own step. const reported = historyStep( - history(test(k, testPath, set)), - sr => result(k, testPath, sr, set.throws)) + history(evaluated), + ([t, sr]) => result(t, sr, set.throws)) return step( reported, - ([, sr]) => { - const { result: [s, r], duration } = sr - if (s !== 'ok') { - return pureOk(addFail(duration)(zero)) - } - if (set.throws) { - return pureOk(addPass(duration)(zero)) + ([, [t, sr]]) => { + const total = addResult(zeroTotals, t) + if (t.status !== 'passed' || set.throws) { + return pureOk(total) } // Walk return-value sub-tree; null marks the call boundary so // paths render as e.g. `outer().inner`. throws resets to false. return mapStep( - walk([...testPath, null], false, r), - sub => mergeState(addPass(duration)(zero), sub)) + walk([...testPath, null], false, sr.result[1]), + sub => mergeTotals(total, sub)) }) } - /** @type {(path: Path, throws: boolean, v: unknown) => Effect} */ + /** @type {(path: Path, throws: boolean, v: unknown) => Effect} */ const walk = (path, throws, v) => { const effects = collectTests(path, throws, v).map(one) - return mapStep(allOk(...effects), states => states.reduce(mergeState, zero)) + return mapStep(allOk(...effects), states => states.reduce(mergeTotals, zeroTotals)) } - return mapStep(walk([], false, v), delta => mergeState(ts, delta)) + return mapStep(walk([], false, v), delta => mergeTotals(ts, delta)) } /** @type {(moduleMap: ModuleMap) => readonly (readonly [string, unknown])[]} */ @@ -217,15 +232,15 @@ export const runModuleMap = reporter => moduleMap => { const { summary } = reporter const modules = proofEntries(moduleMap) const total = mapStep( - allOk(...modules.map(([k, v]) => runModule(reporter)(k, v)(zero))), - m => m.reduce(mergeState, zero)) + allOk(...modules.map(([k, v]) => runModule(reporter)(k, v)(zeroTotals))), + m => m.reduce(mergeTotals, zeroTotals)) // The totals are still needed after the summary has been printed, so they // are carried forward in a history rather than closed over by a nested // continuation. const reported = historyStep( history(total), - ts => summary(ts.pass, ts.fail, ts.time)) - return mapStep(reported, ([, ts]) => ts.fail !== 0 ? 1 : 0) + summary) + return mapStep(reported, ([, ts]) => ts.failed !== 0 ? 1 : 0) } /** @@ -423,13 +438,12 @@ export const defaultReporter = options => { const isGitHub = options.env['GITHUB_ACTIONS'] !== undefined return { // https://github.com/OndraM/ci-detector/blob/main/src/Ci/GitHubActions.php - result: (file, path, r, throws) => { - const t = testResult(file, path, r) + result: (t, r, throws) => { const v = r.result[1] return t.status === 'passed' ? csiLog(fmtResultLine(t, fgGreen, 'ok') + (throws ? ' # EXPECTED TO THROW' : '')) : isGitHub - ? csiError(`::error file=${file},line=1,title=${ghEscape(t.name)}::${ghEscape(String(v))}`) + ? csiError(`::error file=${t.module},line=1,title=${ghEscape(t.name)}::${ghEscape(String(v))}`) // `step`, so the detail line is attempted only when the // header line was written: two halves of one report, and // half of it is worse than none. @@ -437,11 +451,11 @@ export const defaultReporter = options => { csiError(fmtResultLine(t, fgRed, 'error')), () => csiError(`${fgRed}${v}${reset}`)) }, - summary: (pass, fail, time) => { - const fgFail = fail === 0 ? fgGreen : fgRed + summary: ({ passed, failed, duration }) => { + const fgFail = failed === 0 ? fgGreen : fgRed return step( - csiLog(`${bold}Number of tests: pass: ${fgGreen}${pass}${reset}${bold}, fail: ${fgFail}${fail}${reset}${bold}, total: ${pass + fail}${reset}`), - () => csiLog(`${bold}Time: ${timeFormat(time)}${reset}`)) + csiLog(`${bold}Number of tests: pass: ${fgGreen}${passed}${reset}${bold}, fail: ${fgFail}${failed}${reset}${bold}, total: ${passed + failed}${reset}`), + () => csiLog(`${bold}Time: ${timeFormat(duration)}${reset}`)) }, test: defaultTest, } diff --git a/fjs/emergent_testing/proof.f.mjs b/fjs/emergent_testing/proof.f.mjs index a6abb8539..55c1b7694 100644 --- a/fjs/emergent_testing/proof.f.mjs +++ b/fjs/emergent_testing/proof.f.mjs @@ -15,12 +15,12 @@ import { assert, assertEq, todo } from '../asserts/module.f.mjs' import { testAll, fmtPath, fmtTerm, fmtImport, ghEscape, isInteger, isIdentifier, registerModule, parseTestSet, - defaultTest, main, register, testResult, + addResult, defaultTest, main, register, testResult, zeroTotals, } from './module.f.mjs' import { run as mockRun } from '../effects/mock/module.f.mjs' import { shouldLoad } from '../dev/module.f.mjs' import { parse as parseJson } from '../media/json/module.f.mjs' -import { array, number as rttiNumber, or, string as rttiString } from '../rtti/module.f.mjs' +import { number as rttiNumber, or, string as rttiString } from '../rtti/module.f.mjs' import { parse as rttiParse } from '../rtti/parse/module.f.mjs' import { error, ok, unwrap } from '../types/result/module.f.mjs' @@ -35,7 +35,7 @@ import { error, ok, unwrap } from '../types/result/module.f.mjs' * JSON representation to round-trip through anyway. */ const event = or( - /** @type {const} */ (['result', rttiString, array(or(rttiString, null))]), + /** @type {const} */ (['result', rttiString, rttiString]), /** @type {const} */ (['summary', rttiNumber, rttiNumber, rttiNumber]), ) @@ -55,8 +55,12 @@ const parseEvents = stdout => /** @type {() => _TestReporter} */ const makeReporter = () => ({ - result: (file, path, _r, _throws) => writeEvent(['result', file, [...path]]), - summary: (pass, fail, time) => writeEvent(['summary', pass, fail, time]), + // The leaf-landed event arrives with the shared `TestResult` already + // built, so what this writes — and what the proofs below assert on — is + // the record's own `module` and formatted `path`, not a spelling of the + // mock's own. + result: (t, _r, _throws) => writeEvent(['result', t.module, t.path]), + summary: ({ passed, failed, duration }) => writeEvent(['summary', passed, failed, duration]), test: defaultTest, }) @@ -98,8 +102,8 @@ export const flat = () => { }) assertEq(exit, 0) const [e0, e1, e2] = events - assert(e0[0] === 'result' && e0[2][0] === 'a') - assert(e1[0] === 'result' && e1[2][0] === 'b') + assert(e0[0] === 'result' && e0[2] === '.a') + assert(e1[0] === 'result' && e1[2] === '.b') assert(e2[0] === 'summary') const [, pass, fail] = e2 assertEq(pass, 2) @@ -113,8 +117,8 @@ export const nested = () => { }) assertEq(exit, 0) const [e0, e1, e2] = events - assert(e0[0] === 'result' && e0[2][1] === 'add') - assert(e1[0] === 'result' && e1[2][1] === 'sub') + assert(e0[0] === 'result' && e0[2] === '.math.add') + assert(e1[0] === 'result' && e1[2] === '.math.sub') assert(e2[0] === 'summary') const [, pass, fail] = e2 assertEq(pass, 2) @@ -128,7 +132,7 @@ export const throwKey = () => { }) assertEq(exit, 0) const [e0, e1] = events - assert(e0[0] === 'result' && e0[2][0] === 'throw' && e0[2][1] === 'a') + assert(e0[0] === 'result' && e0[2] === '.throw.a') assert(e1[0] === 'summary') const [, pass, fail] = e1 assertEq(pass, 1) @@ -180,8 +184,8 @@ export const returnValueSubTree = () => { const passEvents = events.filter(e => e[0] === 'result') assertEq(passEvents.length, 2) const [p0, p1] = passEvents - assertEq(p0[2][0], 'outer') - assertEq(p1[2][2], 'inner') + assertEq(p0[2], '.outer') + assertEq(p1[2], '.outer().inner') } // integer-indexed array keys appear as numeric path segments @@ -192,8 +196,8 @@ export const arrayKeys = () => { assertEq(exit, 0) const passEvents = events.filter(e => e[0] === 'result') assertEq(passEvents.length, 2) - assertEq(passEvents[0][2][1], '0') - assertEq(passEvents[1][2][1], '1') + assertEq(passEvents[0][2], '.arr[0]') + assertEq(passEvents[1][2], '.arr[1]') } // non-proof files are skipped: plain `.ts` is not loaded; `.f.ts` without @@ -235,7 +239,7 @@ export const throwByFunctionName = () => { assertEq(exit, 0) const passEvents = events.filter(e => e[0] === 'result') assertEq(passEvents.length, 1) - assertEq(passEvents[0][2][0], 'here') + assertEq(passEvents[0][2], '.here') } // only the `proof` export is used; other module properties are ignored @@ -246,8 +250,8 @@ export const namedExports = () => { assertEq(exit, 0) const passEvents = events.filter(e => e[0] === 'result') assertEq(passEvents.length, 2) // `other` is ignored - assertEq(passEvents[0][2][0], 'a') - assertEq(passEvents[1][2][0], 'b') + assertEq(passEvents[0][2], '.a') + assertEq(passEvents[1][2], '.b') } // the default (non-GitHub) reporter formats module/pass/summary lines on stdout @@ -667,8 +671,31 @@ const testResultProofs = { }, } +/** + * `addResult` is where every runner turns a stream of leaf results into the + * run's totals — the summary line, the exit code and the browser report's + * counts all read this fold — so the fold itself is pinned here, not only its + * end-to-end effects. + */ +const runTotalsProofs = { + startsEmpty: () => { + assertEq(zeroTotals.passed, 0) + assertEq(zeroTotals.failed, 0) + assertEq(zeroTotals.duration, 0) + }, + countsByTheSharedStatus: () => { + const pass = testResult('./a.f.mjs', ['x'], { result: ok(1), duration: 0.5 }) + const fail = testResult('./a.f.mjs', ['y'], { result: error('boom'), duration: 2 }) + const totals = [pass, fail, pass].reduce(addResult, zeroTotals) + assertEq(totals.passed, 2) + assertEq(totals.failed, 1) + assertEq(totals.duration, 3) + }, +} + export const proof = { testResult: testResultProofs, + runTotals: runTotalsProofs, throw: { registerBodyPanicsOnUndispatchableEffect, }, diff --git a/fjs/emergent_testing/todo/report-before-running.md b/fjs/emergent_testing/todo/report-before-running.md index 37d8fff98..1a0be4351 100644 --- a/fjs/emergent_testing/todo/report-before-running.md +++ b/fjs/emergent_testing/todo/report-before-running.md @@ -24,8 +24,11 @@ Three things follow from that, and the third is the one that matters: the case where a name is worth more than a result, and it is the case where the current design has none. -No reporter has an event for it: `result` is called with a `SandboxResult`, so it -cannot be called before there is one. +No reporter has an event for it: `result` is called with a finished +`TestResult` and the `SandboxResult` it was read from, so it cannot be called +before there is one. The seam it would travel through does exist now — both +runners report through the same leaf-landed and run-ended events — so adding a +start event is adding a third event kind, not building the stream first. ### Preliminary design diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index c1297405e..231b56a05 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -131,11 +131,23 @@ and is reviewable without the next one. move with them. - [ ] **5. A browser interpreter** for exactly those operations, with no scheduling policy of its own. -- [ ] **6. One reporter.** The event stream — a leaf landed, a run ended — - that both hosts subscribe to. Step 2 gave them the *value*; this gives - them the seam it travels through, and it is what +- [x] **6. One reporter.** The event stream — a leaf landed, a run ended — + that both hosts subscribe to. Step 2 gave them the *value*; this gave + them the seam it travels through. `Reporter.result` now receives the + shared `TestResult` built by the runner instead of raw material every + reporter normalized for itself, and the run-ended event is `RunTotals`, + folded from the leaf results by one `addResult` — the summary line, the + exit code and the browser report's counts and pass/fail status all read + that same fold. This is what [report a test's name before running it](report-before-running.md) - needs before a start event can exist. + needed before a start event could exist: adding one is now a third event + kind on an existing stream. What stayed each host's own, deliberately: + the raw `SandboxResult` still travels next to the `TestResult`, because + describing a *thrown value* is each host's part (step 2's finding); and + the browser report's own `duration` stays wall-clock rather than the + fold's summed durations, because its leaves run concurrently and the sum + only means "how long the run took" for a sequential runner — + `RunTotals` documents that. - [ ] **7. One skeleton.** The page's proof-tree walk is deleted and the shared traversal runs it. - [ ] **8. The layout move**, and the website preparation program. @@ -162,10 +174,14 @@ alone on purpose: Note also that `testResult` now sits inside `fjs t`'s own reporting path, so a defect in it can mislabel the very failures it causes — a mutation forcing every -status to `passed` prints `ok` on failing lines. The pass/fail counts come from -the walk's state rather than from the reporter, so they stay honest and the -summary still reports the failures. Worth remembering when reading output while -changing this function. +status to `passed` prints `ok` on failing lines. Since step 6, the pass/fail +counts and the exit code read the same shared status (the walk folds each +leaf's `TestResult` with `addResult`), so such a defect no longer leaves an +honest summary behind either — that duplicate decision was exactly the drift +this issue exists to remove, and what holds the line now is that `testResult` +and `addResult` are pinned by direct proofs rather than by a second +implementation agreeing. Worth remembering when reading output while changing +either function. ### Why the remaining steps are worth taking diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index ed5888d7d..20ac2f6c9 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -96,13 +96,31 @@ export type TestResult = { readonly duration: number } +/** + * A run's outcome, folded from its leaf results: how many passed, how many + * failed, and how long they took together. + * + * It is built one `TestResult` at a time with `addResult`, starting from + * `zeroTotals`, so a stream of leaf-landed events and a finished totals record + * are the same information at two moments — which is what lets both runners + * answer "did the run pass" (`failed !== 0`) from the same fold. + * + * `duration` is the *sum* of the folded results' durations. For `fjs t`, which + * runs leaves sequentially, that is also the run's time and is what its + * `Time:` line prints. The browser runs leaves concurrently, so the sum stops + * meaning "how long the run took" there; its wire report keeps its own + * wall-clock `duration` and takes only the counts from the fold. + */ +export type RunTotals = { + readonly passed: number + readonly failed: number + readonly duration: number +} + /** * Receives semantic test-run events. Each method is the runner's notification * of an event; the reporter decides how to render it (terminal, GitHub - * annotations, JSON, node `--test`, etc.). `path` is the chain of object keys - * leading to the current location; `null` marks a function-call boundary, e.g. - * `['outer', null, 'inner']` means `outer` was invoked and its return value - * contained `inner`. + * annotations, JSON, node `--test`, etc.). * * **Every method is fallible**, because reporting is IO and IO can fail: a * write to a closed pipe, a runner that cannot dispatch `write` at all. The @@ -123,17 +141,19 @@ export type TestResult = { * through unchanged. */ export type Reporter = { - readonly result: (file: string, path: Path, r: SandboxResult, throws: boolean) => Effect - readonly summary: (pass: number, fail: number, time: number) => Effect + /** + * A leaf landed. The first argument is the shared {@link TestResult} — the + * runner builds it with `testResult` before notifying, so a reporter + * receives the leaf's identity and status rather than deriving its own. + * The raw `SandboxResult` and the throw expectation travel with it because + * describing a *thrown value* is each host's part (see {@link TestResult}), + * and the description needs the value. + */ + readonly result: (t: TestResult, r: SandboxResult, throws: boolean) => Effect + /** The run ended, with the totals folded from every leaf that landed. */ + readonly summary: (totals: RunTotals) => Effect readonly test: (file: string, path: Path, set: TestEntry) => Effect, IoChannel> } -/** @internal */ -export type _TestState = { - readonly time: number, - readonly pass: number, - readonly fail: number, -} - /** @internal */ export type _TestAndPath = readonly [Path, TestEntry] From 2ba5316ea872527291a9bde7d5927f85aca2abff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:30:53 +0000 Subject: [PATCH 133/370] changelog: the Reporter signature change is breaking Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/1749.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/unreleased/1749.md diff --git a/changelog/unreleased/1749.md b/changelog/unreleased/1749.md new file mode 100644 index 000000000..a96aaff7d --- /dev/null +++ b/changelog/unreleased/1749.md @@ -0,0 +1,4 @@ +- **BREAKING CHANGES:** `emergent_testing`: `Reporter.result` now receives the + normalized `TestResult` and `Reporter.summary` one `RunTotals` record; the + new `addResult` and `zeroTotals` fold leaf results into every runner's + totals. Printed output and the browser report are unchanged From f96b0cf10e993d516f549ec3c19a1cc65d4bc523 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:36:44 +0000 Subject: [PATCH 134/370] todo: the add-result issue is dissolved, not just done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-reporter change deleted the `addPass`/`addFail` pair that 66a-emergent-add-result existed to merge — the run's totals are now one fold (`addResult` over each leaf's `TestResult`), so there is no pair left to parameterize. The issue file goes with the code it described, and the two todos that quoted the old identifiers as examples now describe the current shape instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/65z-tf-test-tree-walker.md | 30 ++++--- .../todo/66a-emergent-add-result.md | 79 ------------------- .../66b-sorted-list-cmp-reduce-factory.md | 8 +- 3 files changed, 18 insertions(+), 99 deletions(-) delete mode 100644 fjs/emergent_testing/todo/66a-emergent-add-result.md diff --git a/fjs/emergent_testing/todo/65z-tf-test-tree-walker.md b/fjs/emergent_testing/todo/65z-tf-test-tree-walker.md index 8b47befad..622b07be3 100644 --- a/fjs/emergent_testing/todo/65z-tf-test-tree-walker.md +++ b/fjs/emergent_testing/todo/65z-tf-test-tree-walker.md @@ -23,25 +23,23 @@ const registerOne = (ctx: TestContext, [path, { fn, throws }]: TestAndPath) => return all(...sub.map(e => registerOne(t, e))).step(() => pure(undefined)) })) -// runModule (./fjs/emergent_testing/module.f.mjs:167) -const one = ([testPath, set]: TestAndPath): Effect => +// runModule (./fjs/emergent_testing/module.f.mjs) +const one = ([testPath, set]: TestAndPath): Effect => test(k, testPath, set) .step(sr => { - const { result: [s, r], duration } = sr - return result(k, testPath, sr) - .step((): Effect => { - if (s === 'ok') { - if (set.throws) { return pure(addPass(duration)(zero)) } - return walk([...testPath, null], false, r) - .step(sub => pure(mergeState(addPass(duration)(zero), sub))) - } - return pure(addFail(duration)(zero)) + const t = testResult(k, testPath, sr) + return result(t, sr, set.throws) + .step((): Effect => { + const total = addResult(zeroTotals, t) + if (t.status !== 'passed' || set.throws) { return pure(total) } + return walk([...testPath, null], false, sr.result[1]) + .step(sub => pure(mergeTotals(total, sub))) }) }) -const walk = (path: Path, throws: boolean, v: unknown): Effect => { +const walk = (path: Path, throws: boolean, v: unknown): Effect => { const effects = collectTests(path, throws, v).map(one) return all(...effects) - .step(states => pure(states.reduce(mergeState, zero))) + .step(states => pure(states.reduce(mergeTotals, zeroTotals))) } ``` @@ -93,7 +91,7 @@ export const walkTests = (w: Walker) => { } ``` -`runModule` instantiates `S = TestState`, threads `Sandbox`/`Reporter` effects +`runModule` instantiates `S = RunTotals`, threads `Sandbox`/`Reporter` effects in `onLeaf`, and returns the sub-tree value on success-without-`throws`. `registerModule` instantiates `S = void` for surviving process adapters, registers through @@ -136,8 +134,8 @@ shares the semantics rather than the obsolete Playwright registration path. `onLeaf` may need to return a "child context" alongside the accumulator. This may complicate the signature enough that the abstraction stops feeling like a win; a small spike will tell. -- `runModule` measures per-leaf `duration` from `SandboxResult` and folds it - into `TestState`; `registerModule` doesn't care. The walker must not +- `runModule` builds each leaf's `TestResult` and folds it into `RunTotals` + with `addResult`; `registerModule` doesn't care. The walker must not pretend to own this — it stays inside `onLeaf`. - Browser execution has no `TestContext` and must not import the Node effect runner. Share browser-compatible code only when it keeps the page independent from Node and diff --git a/fjs/emergent_testing/todo/66a-emergent-add-result.md b/fjs/emergent_testing/todo/66a-emergent-add-result.md deleted file mode 100644 index 043b98cd6..000000000 --- a/fjs/emergent_testing/todo/66a-emergent-add-result.md +++ /dev/null @@ -1,79 +0,0 @@ -## 66A-emergent-add-result. Merge `addPass` / `addFail` into one `TestState` updater - -**Priority:** P5 -**Status:** open - -### Problem - -`fjs/emergent_testing/module.f.mjs` defines two `TestState` updaters that are -identical except for the counter field they increment: - -```ts -// fjs/emergent_testing/module.f.mjs:40-46 -const addPass = (delta: number) => (ts: TestState): TestState => - ({ ...ts, time: ts.time + delta, pass: ts.pass + 1 }) - -const addFail = (delta: number) => (ts: TestState): TestState => - ({ ...ts, time: ts.time + delta, fail: ts.fail + 1 }) -``` - -where - -```ts -// :37-41 -type TestState = { - readonly time: number, - readonly pass: number, - readonly fail: number, -} -``` - -The two bodies share the spread, the `time: ts.time + delta` accumulation, and -the `+ 1` increment; they differ only in whether `pass` or `fail` is the -incremented key. This is the "same algorithm, one varying constant" shape that -DRY targets — and if a third outcome counter were ever added (e.g. `skip`), the -copy would multiply. - -Both helpers are real, exercised code: `addPass(duration)(zero)` / -`addFail(duration)(zero)` feed the `runModule` walk -(`fjs/emergent_testing/module.f.mjs:180-190`). - -### Proposal - -Parameterize over the counter key with a typed computed property, keeping the -type checker's exhaustiveness (`'pass' | 'fail'` is a closed union, so a typo -is a compile error): - -```ts -const addResult = (key: 'pass' | 'fail') => (delta: number) => (ts: TestState): TestState => - ({ ...ts, time: ts.time + delta, [key]: ts[key] + 1 }) - -const addPass = addResult('pass') -const addFail = addResult('fail') -``` - -The two named helpers are kept as point-free derivations so every call site -(`addPass(duration)(zero)`, `addFail(duration)(zero)`) is unchanged and still -reads at the grammar level. No `as` cast is needed — `ts[key]` is `number` for -both members of the union, and the computed-key literal is checked against the -`TestState` shape. - -This is a small, single-module change. It is borderline against the AGENTS.md -DRY-vs-readability guidance (the originals are short and clear), which is why -it is filed at **P5** — worth doing if the file is being touched anyway, or as -a prerequisite if a third counter is introduced, but not on its own. - -### Tasks - -- [ ] Replace `addPass` / `addFail` with the `addResult` factory + two - derivations in `fjs/emergent_testing/module.f.mjs`. -- [ ] Confirm `fjs/emergent_testing` proofs still pass (`fjs t`) with full - branch coverage and `npx tsc` is clean. - -### Related - -- [i65Z-tf-test-tree-walker](./65z-tf-test-tree-walker.md) — adjacent - `fjs/emergent_testing` DRY cleanup (sharing the dynamic test-tree walk between - `runModule` and `registerModule`). Same module; independent change. Note that - walker also consumes `addPass`/`mergeState`, so landing this first keeps the - updater surface stable for that refactor. diff --git a/fjs/types/todo/66b-sorted-list-cmp-reduce-factory.md b/fjs/types/todo/66b-sorted-list-cmp-reduce-factory.md index d781f8e23..a148ddabc 100644 --- a/fjs/types/todo/66b-sorted-list-cmp-reduce-factory.md +++ b/fjs/types/todo/66b-sorted-list-cmp-reduce-factory.md @@ -66,10 +66,12 @@ between "merge" and "intersect". This is borderline against the `AGENTS.md` "readability over DRY for short, clear functions" guidance — the originals are three lines each and already readable, and the `select` callback adds an indirection a reader must follow. It is the -same caliber as [i66A-emergent-add-result](../../emergent_testing/todo/66a-emergent-add-result.md) (two -near-identical updaters differing in one slot), filed at the same low priority: +"two near-identical updaters differing in one slot" caliber, filed low for it: worth doing if the file is being touched anyway, or as a prerequisite if a third sign-driven merge reducer is added (e.g. set difference), but not on its own. +(`emergent_testing` had the same shape in its `addPass`/`addFail` pair until a +runner change dissolved the pair into one fold rather than parameterizing it — +which is also a way this kind of issue resolves.) ### Tasks @@ -81,5 +83,3 @@ sign-driven merge reducer is added (e.g. set difference), but not on its own. - i180-sorted-set-intersect-symmetry — adjacent sorted-collection merge/intersect cleanup. -- [i66A-emergent-add-result](../../emergent_testing/todo/66a-emergent-add-result.md) — the same - "two updaters differing in one slot" pattern, filed at the same priority. From 9fc4fd93bc01248dc36ec6f8323e6fc125477169 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:40:34 +0000 Subject: [PATCH 135/370] =?UTF-8?q?rtti:=20join=20the=20tuple=20rebuild's?= =?UTF-8?q?=20segments=20pairwise=20=E2=80=94=20linear=20passes,=20no=20ar?= =?UTF-8?q?gument=20spread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #1748, on the previous fix: accumulating segments with a per-entry spread was quadratic — about 13.5 s for a 15,000-position tuple where the parent took 0.37 s — and the one `concat(...segments)` call overflowed the engine's argument limit on a large enough prefix, throwing past the `Result` API. The segments are now collected on the module's O(1)-prepend list and joined pairwise: each round halves the segment count with one-argument `concat` calls, so the rebuild is one linear pass plus a logarithmic number of halving rounds and no call takes a variable argument list at all. Pinned with an odd segment count and with thousands of alternating present/absent positions, every hole and member asserted. (The remaining cost at that scale sits in `undeclaredMembers`' pre-existing linear `declared.some` scan, which predates this PR and matches the parent's 0.37 s.) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- fjs/rtti/parse/module.f.mjs | 59 ++++++++++++++++++++++++------------- fjs/rtti/parse/proof.f.mjs | 38 ++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 092a91355..ff1f46ec0 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -85,6 +85,21 @@ const arrayRebuild = entries => entries.map(([, v]) => v) /** @type {_Rebuild} */ const recordRebuild = entries => Object.fromEntries(entries) +/** + * One pairwise round of {@link tupleRebuild}'s join: adjacent segments are + * `concat`enated — always one argument, so no call ever spreads the segment + * list — halving the count while copying every element once. `concat` + * appends a spreadable operand element by *present* element, which is what + * keeps the holes; each receiver is a trusted plain array, so its species + * is `Array`. + * + * @type {(segments: ReadonlyArray>) => ReadonlyArray>} + */ +const joinSegmentsRound = segments => segments.flatMap((s, i) => + i % 2 !== 0 ? [] + : i + 1 < segments.length ? [s.concat(segments[i + 1])] + : [s]) + /** * The **tuple** kind's rebuild over its declared members — only the present * ones reach `entries` (`recordRebuild` is the struct kind's counterpart, @@ -95,37 +110,41 @@ const recordRebuild = entries => Object.fromEntries(entries) * different value, and omitting it would shift every position after it). * * The construction is segments — a fresh `new Array(gap)` of holes before - * each present member, then the member — folded with `concat` on a trusted - * empty array, which appends a spreadable argument element by *present* - * element and so keeps the holes. The input value is never consulted, and - * every array touched is a plain one this module made, which is the point: - * an earlier slice-then-map of the input let an accepted `Array` subclass - * override `slice` and hand `parse` a result that fails the very schema it - * was parsed against. An index the value only *inherits* is a present - * member (HasProperty is what the check dispatched on), so it sits in - * `entries` and is materialized as an own member of the result, carrying - * its parsed value — see `../host.proof.mjs`. + * each present member, then the member — collected on an O(1)-prepend list + * and joined pairwise ({@link joinSegmentsRound}), so the whole rebuild is + * one linear pass plus a logarithmic number of halving rounds: re-spreading + * the accumulated segments per entry was quadratic, and one + * `concat(...segments)` call overflowed the engine's argument limit on a + * large enough prefix, throwing past the `Result` API. + * + * The input value is never consulted, and every array touched is a plain + * one this module made, which is the point: an earlier slice-then-map of + * the input let an accepted `Array` subclass override `slice` and hand + * `parse` a result that fails the very schema it was parsed against. An + * index the value only *inherits* is a present member (HasProperty is what + * the check dispatched on), so it sits in `entries` and is materialized as + * an own member of the result, carrying its parsed value — see + * `../host.proof.mjs`. * * @type {_Rebuild} */ const tupleRebuild = entries => { - /** @type {readonly (readonly Unknown[])[]} */ - let segments = [] + /** @type {List>} */ + let reversed = null let next = 0 for (const [k, v] of entries) { const i = Number(k) - segments = i === next - ? [...segments, [v]] - : [...segments, new Array(i - next), [v]] + if (i > next) { reversed = { first: new Array(i - next), tail: reversed } } + reversed = { first: [v], tail: reversed } next = i + 1 } - return emptySegment.concat(...segments) + let segments = toArray(reverse(reversed)) + while (segments.length > 1) { + segments = joinSegmentsRound(segments) + } + return segments.length === 0 ? [] : segments[0] } -/** `tupleRebuild`'s trusted `concat` receiver — a plain array, so its species is `Array`. */ -/** @type {ReadonlyArray} */ -const emptySegment = [] - /** `eachEntry`'s accumulator seed: entries are consed on in reverse as they parse. */ /** @type {List} */ const emptyEntries = null diff --git a/fjs/rtti/parse/proof.f.mjs b/fjs/rtti/parse/proof.f.mjs index 6a21d58a3..6531809db 100644 --- a/fjs/rtti/parse/proof.f.mjs +++ b/fjs/rtti/parse/proof.f.mjs @@ -359,6 +359,44 @@ export const proof = { const built = unwrap(parse({ a: number, b: or(option, string) })({ a: 1 })) assert(!('b' in built), 'an absent key is not materialized') }, + // An odd segment count: hole, present, present — three segments, two + // pairwise join rounds, the tail segment carried once unpaired. + oddSegments: () => { + /** @type {ReadonlyArray} */ + const built = unwrap(parse([or(option, number), number, number])([, 2, 4])) + assertEq(built.length, 3, 'the hole keeps its position') + assert(!Object.hasOwn(built, 0), 'no own index 0') + assertEq(built[1], 2, '`2` stays at index 1') + assertEq(built[2], 4, 'and `4` at index 2') + }, + // The join at scale: alternating present and absent positions, so + // thousands of segments go through a dozen halving rounds. The two + // hazards this construction replaced — re-spreading the accumulated + // segments per entry (quadratic) and one spread `concat` call over + // all of them (the engine's argument limit, a throw past the + // `Result` API) — are structurally gone: no call in the rebuild + // takes a variable argument list at all. Correctness of every hole + // and every member is what is asserted. + largeSparse: () => { + const pairs = 2048 + const omittable = or(option, number) + const schema = Array.from( + { length: 2 * pairs }, + (_, i) => i % 2 === 0 ? number : omittable) + // `[7, hole]` chunks: a sparse value FunctionalScript can build + // without mutation, holes at every odd index + /** @type {ReadonlyArray} */ + const chunk = [7].concat(new Array(1)) + /** @type {ReadonlyArray} */ + const none = [] + const value = none.concat(...Array.from({ length: pairs }, () => chunk)) + /** @type {ReadonlyArray} */ + const built = unwrap(parse(schema)(value)) + assertEq(built.length, 2 * pairs - 1, 'ends at the last present position') + assert(Array.from({ length: pairs }, (_, i) => i).every(i => + built[2 * i] === 7 && !Object.hasOwn(built, 2 * i + 1)), + 'every present member survives and every interior hole stays a hole') + }, }, path: { rootMismatch: () => assertErrorPath([])(parse(number)('not a number')), From e934de8ed18985c4e0b13a70fde42075dc9af7cd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:41:29 +0000 Subject: [PATCH 136/370] fjs: keep private types out of authored .mjs (Stage 1) Implement Stage 1 of fjs/todo/separate-private-types.md: no authored .mjs anywhere in the repository carries a file-scope JSDoc typedef any more; function-local typedefs remain allowed. Each former file-scope typedef moved to its sibling types.ts when it is part of the public declaration closure, to a new optional private.ts when implementation-private and worth naming (asn.1, bnf, common/monoid, djs, effects/node, rtti, types/bigfloat, types/btree/remove), inline into its annotations when trivial, or function-local into a proof when it is a compile-time assert (edag, effects, js/keywords, rtti, djs/parser, media/json/schema, media/revision). fjs/effects/types.ts no longer imports implementation functions: its ReturnType signature asserts now live in fjs/effects/proof.f.mjs. Recursive RTTI constants stay in module.f.mjs with their consistency asserts moved downstream. Breaking type import-path moves, with importers updated and no compatibility re-exports: Grammar (fjs/fsm) and MemoryOperationMap, MemoryRun, Uuid (fjs/effects/node/memory) moved to sibling types.ts files; the Unknown alias of fjs/media/json/schema is now spelled Ts. Policy documentation updated: root AGENTS.md and fjs/AGENTS.md state the prohibition and placement rules; fjs/fsc/README.md's typedef prescription is rewritten as "Private types"; the blocked wait-for-@internal/stripInternal TODO is deleted as superseded; the typedef prescriptions in todo/migrate-typescript-to-mjs.md, fjs/ci/todo/f-mjs-package-support.md, and fjs/effects/memory/todo/sync-interpreter-owner.md are retargeted to the Stage 1 forms. Stage 1 checkboxes in the design TODO are checked; the file stays for Stage 2 (packaging cleanup). npx tsc clean; full suite 3487/3487. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- AGENTS.md | 5 +- fjs/AGENTS.md | 53 ++- fjs/asn.1/module.f.mjs | 29 +- fjs/asn.1/private.ts | 22 ++ fjs/bnf/data/module.f.mjs | 10 +- fjs/bnf/data/private.ts | 21 ++ fjs/bnf/data/types.ts | 5 + fjs/bnf/descent/module.f.mjs | 24 +- fjs/bnf/descent/private.ts | 27 ++ fjs/bnf/ll1/module.f.mjs | 89 +---- fjs/bnf/ll1/private.ts | 87 +++++ fjs/bnf/matcher/module.f.mjs | 8 +- fjs/bnf/module.f.mjs | 10 +- fjs/bnf/private.ts | 30 ++ fjs/bnf/testlib.f.mjs | 21 +- fjs/cas/proof.f.mjs | 6 +- fjs/ci/todo/f-mjs-package-support.md | 38 +- fjs/common/monoid/module.f.mjs | 21 +- fjs/common/monoid/private.ts | 19 + fjs/crypto/sha2/module.f.mjs | 28 +- fjs/crypto/sign/module.f.mjs | 4 +- fjs/crypto/sign/types.ts | 4 + fjs/djs/ast/module.f.mjs | 14 +- fjs/djs/ast/private.ts | 23 ++ fjs/djs/module.f.mjs | 5 +- fjs/djs/parser/module.f.mjs | 102 +----- fjs/djs/parser/private.ts | 58 +++ fjs/djs/parser/proof.f.mjs | 28 +- fjs/djs/serializer/module.f.mjs | 30 +- fjs/djs/serializer/private.ts | 22 ++ fjs/djs/serializer/types.ts | 14 + fjs/djs/tokenizer/module.f.mjs | 31 +- fjs/djs/tokenizer/private.ts | 27 ++ fjs/djs/types.ts | 4 + fjs/edag/amnesia/module.f.mjs | 20 +- fjs/edag/amnesia/proof.f.mjs | 19 +- fjs/edag/module.f.mjs | 93 +---- fjs/edag/proof.f.mjs | 67 +++- fjs/effects/memory/proof.f.mjs | 8 +- .../memory/todo/sync-interpreter-owner.md | 16 +- fjs/effects/node/memory/module.mjs | 14 +- fjs/effects/node/memory/proof.mjs | 2 +- fjs/effects/node/memory/types.ts | 19 + fjs/effects/node/module.mjs | 46 +-- fjs/effects/node/private.ts | 38 ++ fjs/effects/proof.f.mjs | 116 +++++- fjs/effects/types.ts | 75 +--- fjs/emergent_testing/browser.mjs | 24 +- fjs/emergent_testing/browser/proof.mjs | 161 +++++---- fjs/emergent_testing/proof.f.mjs | 52 ++- fjs/emergent_testing/types.ts | 38 ++ fjs/fsc/README.md | 68 ++-- fjs/fsc/module.f.mjs | 9 +- fjs/fsc/types.ts | 16 + fjs/fsm/module.f.mjs | 7 +- fjs/fsm/proof.f.mjs | 2 +- fjs/fsm/types.ts | 18 + fjs/js/keywords/module.f.mjs | 14 +- fjs/js/keywords/proof.f.mjs | 14 + fjs/mcp/cas/module.f.mjs | 12 +- fjs/media/html/module.f.mjs | 6 +- fjs/media/json/schema/module.f.mjs | 97 +++-- fjs/media/json/schema/proof.f.mjs | 155 ++++---- fjs/media/revision/proof.f.mjs | 15 +- fjs/media/revision/types.ts | 16 +- fjs/protocol/mcp/proof.f.mjs | 27 +- fjs/rtti/common/proof.f.mjs | 4 +- fjs/rtti/data/module.f.mjs | 31 +- fjs/rtti/data/private.ts | 39 ++ fjs/rtti/data/proof.f.mjs | 256 ++++++++------ fjs/rtti/module.f.mjs | 18 +- fjs/rtti/parse/module.f.mjs | 15 +- fjs/rtti/parse/proof.f.mjs | 28 +- fjs/rtti/proof.f.mjs | 13 +- fjs/rtti/ts/module.f.mjs | 9 +- fjs/rtti/ts/private.ts | 14 + fjs/rtti/ts/proof.f.mjs | 189 +++++----- fjs/rtti/validate/proof.f.mjs | 332 ++++++++++-------- fjs/sul/level/hash/proof.f.mjs | 10 +- fjs/sul/module.f.mjs | 8 +- fjs/sul/proof.f.mjs | 6 +- fjs/text/sgr/module.f.mjs | 8 +- fjs/text/utf16/module.f.mjs | 13 +- fjs/todo/separate-private-types.md | 38 +- fjs/types/bigfloat/module.f.mjs | 11 +- fjs/types/bigfloat/private.ts | 16 + fjs/types/bigint/proof.f.mjs | 8 +- fjs/types/bit_vec/module.f.mjs | 11 +- fjs/types/bit_vec/types.ts | 8 + fjs/types/btree/remove/module.f.mjs | 25 +- fjs/types/btree/remove/private.ts | 19 + fjs/types/btree/set/module.f.mjs | 11 +- fjs/types/byte_set/module.f.mjs | 4 +- fjs/types/byte_set/types.ts | 3 + .../todo/uncurry-accumulator-types.md | 9 +- fjs/types/number/module.f.mjs | 4 +- fjs/types/object/proof.f.mjs | 14 +- fjs/types/patricia_trie/proof.f.mjs | 8 +- fjs/types/range_map/module.f.mjs | 6 +- fjs/types/sorted_list/module.f.mjs | 8 +- fjs/web/module.f.mjs | 24 +- fjs/website/browser-prepare.mjs | 9 +- fjs/website/browser-source.mjs | 6 +- .../jsdoc-typedef-doc-declaration-emit.md | 9 +- todo/blocked/jsdoc-typedef-strip-internal.md | 113 ------ todo/migrate-typescript-to-mjs.md | 70 ++-- todo/proof.f.mjs | 3 +- 107 files changed, 1895 insertions(+), 1738 deletions(-) create mode 100644 fjs/asn.1/private.ts create mode 100644 fjs/bnf/data/private.ts create mode 100644 fjs/bnf/descent/private.ts create mode 100644 fjs/bnf/ll1/private.ts create mode 100644 fjs/bnf/private.ts create mode 100644 fjs/common/monoid/private.ts create mode 100644 fjs/djs/ast/private.ts create mode 100644 fjs/djs/parser/private.ts create mode 100644 fjs/djs/serializer/private.ts create mode 100644 fjs/djs/serializer/types.ts create mode 100644 fjs/djs/tokenizer/private.ts create mode 100644 fjs/effects/node/memory/types.ts create mode 100644 fjs/effects/node/private.ts create mode 100644 fjs/fsc/types.ts create mode 100644 fjs/fsm/types.ts create mode 100644 fjs/rtti/data/private.ts create mode 100644 fjs/rtti/ts/private.ts create mode 100644 fjs/types/bigfloat/private.ts create mode 100644 fjs/types/btree/remove/private.ts delete mode 100644 todo/blocked/jsdoc-typedef-strip-internal.md diff --git a/AGENTS.md b/AGENTS.md index 119fabd00..2ac2a2693 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,10 @@ 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. +written in JSDoc with a sibling `types.ts` for a type-level API. No authored +`.mjs` anywhere in the repository — `fjs/` or not — may contain a **file-scope** +JSDoc `@typedef`; function-local typedefs are allowed. Named types live in +`types.ts` (the public declaration closure) or an optional `private.ts`. Testing, documentation, and the full coding style: [fjs/AGENTS.md](./fjs/AGENTS.md). diff --git a/fjs/AGENTS.md b/fjs/AGENTS.md index eb11b882b..74f3a8eb5 100644 --- a/fjs/AGENTS.md +++ b/fjs/AGENTS.md @@ -247,15 +247,42 @@ 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 +No authored `.mjs` may contain a **file-scope** JSDoc `@typedef` — anywhere in +the repository, whatever the directory or basename. Function-local typedefs are +allowed, and are the normal home for compile-time proof types (see the +`consistency` and `signatures` entries in `fjs/edag/proof.f.mjs` and +`fjs/effects/proof.f.mjs`). A named file-scope type goes to one of: + +- the sibling `types.ts` when it is part of the **public declaration closure** — + public types, plus any private `_` helper a shipped public declaration + reaches transitively (e.g. `_Byte` in `fjs/types/byte_set/types.ts`) — or the + type is inlined into the annotation instead; +- an optional sibling `private.ts` for implementation-private types outside the + public closure, when separating them reads cleaner than inlining (e.g. + `fjs/common/monoid/private.ts`, `fjs/rtti/data/private.ts`); do not create it + mechanically for every `_` name; +- nowhere: a short type used once or twice is simply inlined. + +Name private types and private runtime constants with a leading `_`, even when +module linkage requires an export: exportability is linkage, not API status, so +renaming or removing a `_`-prefixed name is not by itself a breaking change. +The public contract still governs transitive effects. See +[Private types](./fsc/README.md#private-types) for the full rule. + +The intra-directory dependency direction is +`types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs` +(dependency to dependent; a layering guide, not a requirement that every file +exists). `types.ts` must not depend on `private.ts`, and verification moves +downstream: an assertion that checks the implementation belongs in a proof +function, not in `types.ts`. Recursive RTTI whose annotation needs a named +public type may stay in `module.f.mjs` (e.g. `exp` in `fjs/edag/module.f.mjs`), +and declarative compile-time/runtime constants shared between TypeScript and +runtime code may be split into a normal subordinate metaprogramming module such +as `meta/module.f.mjs` when that helps — it is an ordinary module, discovered +and covered like any other `module.f.mjs`, never a requirement. + +Use `@typedef` (function-local in `.mjs`, or `export type` in `types.ts` / +`private.ts`) for a named type and `@template` for its type parameters. A constraint goes in braces before the parameter name: ```js @@ -535,10 +562,10 @@ that context on its own: `ToAsyncOperationMap` is a mapped type keyed on back out of the argument. Left to argument inference `O` falls back to its `Operation` constraint — payloads and outputs `never` — which no real map is assignable to, and the call site reaches for exactly the cast this section warns -about. **Annotate the result instead**: pin the runner's own type -(`/** @type {_EffectToPromise} */`, `/** @type {MemoryRun} */`) and `O` is -inferred from the return type, giving the call a real `O` to check its argument -against. Both Node runners are written that way — +about. **Annotate the result instead**: pin the runner's own type — an inline +generic annotation, or a `types.ts` name such as `/** @type {MemoryRun} */` — +and `O` is inferred from the return type, giving the call a real `O` to check +its argument against. Both Node runners are written that way — `fjs/effects/node/module.mjs`'s `runNodeEffect` and `fjs/effects/node/memory/module.mjs`'s `memoryRun`. diff --git a/fjs/asn.1/module.f.mjs b/fjs/asn.1/module.f.mjs index 35b997354..efe4c8c1a 100644 --- a/fjs/asn.1/module.f.mjs +++ b/fjs/asn.1/module.f.mjs @@ -6,6 +6,7 @@ * * @import { Unpacked, Vec } from '../types/bit_vec/types.ts' * @import { ObjectIdentifier, Raw, Record, Sequence, SupportedRecord, _Tag } from './types.ts' + * @import { _ClassPc, _ParsedTag } from './private.ts' */ import { bitLength, divUp8 } from '../types/bigint/module.f.mjs' @@ -32,29 +33,10 @@ const pop8 = pop(8n) // tag -/** - * @typedef {| - * 0b000_00000n | - * 0b001_00000n | - * 0b010_00000n | - * 0b011_00000n | - * 0b100_00000n | - * 0b101_00000n | - * 0b110_00000n | - * 0b111_00000n - * } _ClassPc - */ - const classPcMask = 0b111_00000n const tagNumberMask = 0b000_11111n -/** - * Note: the tag number (the second parameter) can be arbitrarily large, - * so we can't just use a single byte to represent it. - * @typedef {readonly[_ClassPc, bigint]} _ParsedTag - */ - /** @type {([classPc, number]: _ParsedTag) => Vec} */ const parsedTagEncode = ([classPc, number]) => { const [firstByteNumber, rest] = number < tagNumberMask @@ -140,14 +122,7 @@ export const constructedSet = 0x31n // constructed | set // -/** - * @typedef {{ - * readonly byteLen: bigint - * readonly v: Vec - * }} _Round8 - */ - -/** @type {(_: Unpacked) => _Round8} */ +/** @type {(_: Unpacked) => { readonly byteLen: bigint, readonly v: Vec }} */ const round8 = ({ length, uint }) => { const byteLen = divUp8(length) return { byteLen, v: vec(byteLen << 3n)(uint) } diff --git a/fjs/asn.1/private.ts b/fjs/asn.1/private.ts new file mode 100644 index 000000000..2b15d542d --- /dev/null +++ b/fjs/asn.1/private.ts @@ -0,0 +1,22 @@ +/** + * Implementation-private types for ASN.1 tag encoding. + * + * @module + */ + +/** The top three bits of a tag's first byte: class and constructed flag. */ +export type _ClassPc = + | 0b000_00000n + | 0b001_00000n + | 0b010_00000n + | 0b011_00000n + | 0b100_00000n + | 0b101_00000n + | 0b110_00000n + | 0b111_00000n + +/** + * Note: the tag number (the second element) can be arbitrarily large, + * so we can't just use a single byte to represent it. + */ +export type _ParsedTag = readonly [_ClassPc, bigint] diff --git a/fjs/bnf/data/module.f.mjs b/fjs/bnf/data/module.f.mjs index ffb97fc3f..68eeff4b7 100644 --- a/fjs/bnf/data/module.f.mjs +++ b/fjs/bnf/data/module.f.mjs @@ -14,9 +14,9 @@ * @module * * @import { DataRule, Rule as FRule, Sequence as FSequence } from '../types.ts' - * @import { StringMap } from '../../types/object/types.ts' * @import { StringSet } from '../../types/string_set/types.ts' - * @import { EmptyTag, Repeat, Rule, RuleSet, Sequence, Variant } from './types.ts' + * @import { EmptyTag, Repeat, Rule, RuleSet, Sequence, Variant, _EmptyTagMap } from './types.ts' + * @import { _FRuleMap, _NewRule } from './private.ts' */ import { stringToCodePointList } from '../../text/utf16/module.f.mjs' @@ -37,8 +37,6 @@ import { contains, set } from '../../types/string_set/module.f.mjs' */ export const isRepeat = rule => typeof rule === 'string' -/** @typedef {StringMap} _EmptyTagMap */ - /** @type {(map: _EmptyTagMap) => (rule: Rule) => EmptyTag} */ const emptyTagOf = map => rule => { if (typeof rule === 'number') { @@ -102,8 +100,6 @@ export const emptyTagMap = ruleSet => { // -/** @typedef {StringMap} _FRuleMap */ - const { entries } = Object /** @type {(map: _FRuleMap) => (fr: FRule) => string | undefined} */ @@ -127,8 +123,6 @@ const newName = (map, name) => { return result } -/** @typedef {(m: _FRuleMap) => readonly [_FRuleMap, RuleSet, Rule]} _NewRule */ - /** @type {(list: FSequence) => _NewRule} */ const sequence = list => map => { /** @type {Sequence} */ diff --git a/fjs/bnf/data/private.ts b/fjs/bnf/data/private.ts new file mode 100644 index 000000000..232838eef --- /dev/null +++ b/fjs/bnf/data/private.ts @@ -0,0 +1,21 @@ +/** + * Implementation-private types for the `toData` conversion. + * + * @module + */ + +import type { Rule as FRule } from '../types.ts' +import type { StringMap } from '../../types/object/types.ts' +import type { Rule, RuleSet } from './types.ts' + +/** + * Functional rules already converted, keyed by the generated rule name — the + * memo that keeps a shared functional rule one named data rule. + */ +export type _FRuleMap = StringMap + +/** + * One conversion step: given the memo so far, produces the extended memo, the + * rules the step generated, and the converted rule itself. + */ +export type _NewRule = (m: _FRuleMap) => readonly [_FRuleMap, RuleSet, Rule] diff --git a/fjs/bnf/data/types.ts b/fjs/bnf/data/types.ts index 5f1104640..9615b5b33 100644 --- a/fjs/bnf/data/types.ts +++ b/fjs/bnf/data/types.ts @@ -59,3 +59,8 @@ export type RuleSet = Readonly> * variant branch. */ export type EmptyTag = string | true | undefined + +/** + * The {@link EmptyTag} of every rule in a {@link RuleSet}, keyed by rule name. + */ +export type _EmptyTagMap = StringMap diff --git a/fjs/bnf/descent/module.f.mjs b/fjs/bnf/descent/module.f.mjs index 70bd90998..468923ddc 100644 --- a/fjs/bnf/descent/module.f.mjs +++ b/fjs/bnf/descent/module.f.mjs @@ -29,8 +29,9 @@ * @import { Rule as DataRule, RuleSet, Sequence } from '../data/types.ts' * @import { Rule as FRule } from '../types.ts' * @import { List } from '../../types/list/types.ts' - * @import { Ast, AstResult, AstSequence, AstTag, Cursor } from '../matcher/types.ts' + * @import { Ast, AstSequence, AstTag, Cursor } from '../matcher/types.ts' * @import { CodePointMeta, DescentFailure, DescentMatch, DescentMatchResult, DescentMatchRule } from './types.ts' + * @import { _Failure, _Result } from './private.ts' */ import { rangeDecode } from '../module.f.mjs' @@ -40,27 +41,6 @@ import { definedEntries } from '../../types/object/module.f.mjs' import { emptyTagMap, isRepeat, toData } from '../data/module.f.mjs' import { leafAt, mrFail, mrSuccess, physicalIdx, symbolAt } from '../matcher/module.f.mjs' -/** - * The furthest-failure record while matching, positioned by the complete - * {@link Cursor}. {@link DescentFailure} is its public, physically-positioned - * form. - * - * @typedef {{ - * readonly pos: Cursor - * readonly expected: readonly TerminalRange[] - * }} _Failure - */ - -/** - * The machine's own result: a {@link DescentMatchResult} positioned by the - * complete cursor, and with no failure record — that one is tracked per match - * rather than per frame. This backend always has a position, so it needs no - * `null` case. - * - * @template T - * @typedef {AstResult, Cursor>} _Result - */ - /** * A leaf here is a code point with its metadata, so its symbol is the first * half. This is the only thing {@link symbolAt} needs to know about a leaf. diff --git a/fjs/bnf/descent/private.ts b/fjs/bnf/descent/private.ts new file mode 100644 index 000000000..b200ed5b0 --- /dev/null +++ b/fjs/bnf/descent/private.ts @@ -0,0 +1,27 @@ +/** + * Implementation-private types for the recursive descent matcher backend. + * + * @module + */ + +import type { TerminalRange } from '../types.ts' +import type { AstResult, Cursor } from '../matcher/types.ts' +import type { CodePointMeta, DescentFailure } from './types.ts' + +/** + * The furthest-failure record while matching, positioned by the complete + * {@link Cursor}. {@link DescentFailure} is its public, physically-positioned + * form. + */ +export type _Failure = { + readonly pos: Cursor + readonly expected: readonly TerminalRange[] +} + +/** + * The machine's own result: a `DescentMatchResult` positioned by the complete + * cursor, and with no failure record — that one is tracked per match rather + * than per frame. This backend always has a position, so it needs no `null` + * case. + */ +export type _Result = AstResult, Cursor> diff --git a/fjs/bnf/ll1/module.f.mjs b/fjs/bnf/ll1/module.f.mjs index d44b82c5e..378c10e8e 100644 --- a/fjs/bnf/ll1/module.f.mjs +++ b/fjs/bnf/ll1/module.f.mjs @@ -28,11 +28,10 @@ * @import { CodePoint } from '../../text/utf16/types.ts' * @import { Properties } from '../../types/range_map/types.ts' * @import { StringSet } from '../../types/string_set/types.ts' - * @import { List } from '../../types/list/types.ts' - * @import { RuleSet, Sequence } from '../data/types.ts' - * @import { Ast, AstResult, AstSequence, AstTag, Cursor } from '../matcher/types.ts' + * @import { RuleSet } from '../data/types.ts' * @import { Rule as FRule } from '../types.ts' * @import { Match, MatchResult, Remainder, _Dispatch, _DispatchBranch, _DispatchMap, _DispatchResult, _DispatchRule } from './types.ts' + * @import { _Position, _Result, _Stack, _Task } from './private.ts' */ import { strictEqual } from '../../types/function/operator/module.f.mjs' @@ -163,90 +162,6 @@ export const parser = fr => { return parserRuleSet(data[0]) } -/** - * Where a match stopped: a {@link Cursor}, or `null` when it ran out of input — - * the `null` {@link Remainder} this backend reports for that. - * - * @typedef {Cursor|null} _Position - */ - -/** - * The machine's own result: a {@link MatchResult} positioned by a cursor - * instead of by a materialized remainder. - * - * @typedef {AstResult} _Result - */ - -/** - * A suspended sequence match: `items[itemIndex]` is being matched by the - * current task, and `seq` holds the ASTs of the items already matched. - * - * @typedef {{ - * readonly kind: 'seq' - * readonly tag: AstTag - * readonly items: Sequence - * readonly itemIndex: number - * readonly seq: AstSequence - * }} _SeqFrame - */ - -/** - * A suspended repetition: the item is being matched by the current task for - * one more round, and `items` holds the ASTs of the rounds that already - * completed. They accumulate as a list rather than an array because a - * repetition is as long as its input: appending to an array per round would - * copy the whole prefix each time and make one repetition quadratic in the - * number of items it matched. - * - * @typedef {{ - * readonly kind: 'repeat' - * readonly tag: AstTag - * readonly item: string - * readonly items: _Items - * }} _RepeatFrame - */ - -/** @typedef {List>} _Items */ - -/** @typedef {_SeqFrame | _RepeatFrame} _Frame */ - -/** - * Immutable cons-cell stack: O(1) push/pop, no array copying per step. - * - * @typedef {null | { - * readonly top: _Frame - * readonly rest: _Stack - * }} _Stack - */ - -/** - * The rule invocation about to be evaluated, or `null` when a result is ready - * to resume the innermost frame instead. - * - * @typedef {{ - * readonly kind: 'rule' - * readonly name: string - * readonly tag: AstTag - * readonly pos: Cursor - * }} _RuleTask - */ - -/** - * The next round of a repetition, about to be decided by lookahead. Both the - * rule that introduces a repetition and the frame that finishes one of its - * rounds go through this, so a round is set up in exactly one place. - * - * @typedef {{ - * readonly kind: 'repeat' - * readonly tag: AstTag - * readonly item: string - * readonly items: _Items - * readonly pos: Cursor - * }} _RepeatTask - */ - -/** @typedef {_RuleTask | _RepeatTask} _Task */ - /** * A leaf here is the code point itself, so it *is* its own symbol. The * annotation pins `identity`'s type parameter, which `symbolAt`'s own cannot diff --git a/fjs/bnf/ll1/private.ts b/fjs/bnf/ll1/private.ts new file mode 100644 index 000000000..55e2d0930 --- /dev/null +++ b/fjs/bnf/ll1/private.ts @@ -0,0 +1,87 @@ +/** + * Implementation-private types for the LL(1) matcher machine. + * + * @module + */ + +import type { CodePoint } from '../../text/utf16/types.ts' +import type { List } from '../../types/list/types.ts' +import type { Sequence } from '../data/types.ts' +import type { Ast, AstResult, AstSequence, AstTag, Cursor } from '../matcher/types.ts' + +/** + * Where a match stopped: a {@link Cursor}, or `null` when it ran out of input — + * the `null` `Remainder` this backend reports for that. + */ +export type _Position = Cursor | null + +/** + * The machine's own result: a `MatchResult` positioned by a cursor instead of + * by a materialized remainder. + */ +export type _Result = AstResult + +/** + * A suspended sequence match: `items[itemIndex]` is being matched by the + * current task, and `seq` holds the ASTs of the items already matched. + */ +export type _SeqFrame = { + readonly kind: 'seq' + readonly tag: AstTag + readonly items: Sequence + readonly itemIndex: number + readonly seq: AstSequence +} + +/** + * A suspended repetition: the item is being matched by the current task for + * one more round, and `items` holds the ASTs of the rounds that already + * completed. They accumulate as a list rather than an array because a + * repetition is as long as its input: appending to an array per round would + * copy the whole prefix each time and make one repetition quadratic in the + * number of items it matched. + */ +export type _RepeatFrame = { + readonly kind: 'repeat' + readonly tag: AstTag + readonly item: string + readonly items: _Items +} + +export type _Items = List> + +export type _Frame = _SeqFrame | _RepeatFrame + +/** + * Immutable cons-cell stack: O(1) push/pop, no array copying per step. + */ +export type _Stack = null | { + readonly top: _Frame + readonly rest: _Stack +} + +/** + * The rule invocation about to be evaluated, or `null` when a result is ready + * to resume the innermost frame instead. + */ +export type _RuleTask = { + readonly kind: 'rule' + readonly name: string + readonly tag: AstTag + readonly pos: Cursor +} + +/** + * The next round of a repetition, about to be decided by lookahead. Both the + * rule that introduces a repetition and the frame that finishes one of its + * rounds go through this, so a round is set up in exactly one place. + */ +export type _RepeatTask = { + readonly kind: 'repeat' + readonly tag: AstTag + readonly item: string + readonly items: _Items + readonly pos: Cursor +} + +export type _Task = _RuleTask | _RepeatTask diff --git a/fjs/bnf/matcher/module.f.mjs b/fjs/bnf/matcher/module.f.mjs index 03bc447fe..871d81811 100644 --- a/fjs/bnf/matcher/module.f.mjs +++ b/fjs/bnf/matcher/module.f.mjs @@ -56,13 +56,7 @@ export const symbolAt = symbolOf => (input, pos) => */ export const physicalIdx = length => pos => Math.min(pos, length) -/** - * @template L - * @template P - * @typedef {(tag: AstTag, sequence: AstSequence, pos: P) => AstResult} _Mr - */ - -/** @type {(success: boolean) => _Mr} */ +/** @type {(success: boolean) => (tag: AstTag, sequence: AstSequence, pos: P) => AstResult} */ const mr = success => (tag, sequence, pos) => ({ ast: { tag, sequence }, success, pos }) /** diff --git a/fjs/bnf/module.f.mjs b/fjs/bnf/module.f.mjs index 37c2311ae..9dd292053 100644 --- a/fjs/bnf/module.f.mjs +++ b/fjs/bnf/module.f.mjs @@ -167,20 +167,18 @@ export const range = ab => { return rangeEncode(...a) } -/** @typedef {readonly TerminalRange[]} _RangeList */ - /** @type {(r: TerminalRange) => readonly [string, TerminalRange]} */ const rangeToEntry = r => ['0x' + r.toString(16), r] -/** @type {(r: _RangeList) => RangeVariant} */ +/** @type {(r: readonly TerminalRange[]) => RangeVariant} */ const toVariantRangeSet = r => fromEntries(r.map(rangeToEntry)) -/** @type {(list: _RangeList, ab: number) => _RangeList} */ +/** @type {(list: readonly TerminalRange[], ab: number) => readonly TerminalRange[]} */ const removeOne = (list, ab) => { const [a, b] = rangeDecode(ab) - /** @type {_RangeList} */ + /** @type {readonly TerminalRange[]} */ let result = [] for (const ab0 of list) { const [a0, b0] = rangeDecode(ab0) @@ -200,7 +198,7 @@ const removeOne = (list, ab) => { /** @type {(range: TerminalRange, v: RangeVariant) => RangeVariant} */ export const remove = (range, v) => { - /** @type {_RangeList} */ + /** @type {readonly TerminalRange[]} */ let result = [range] for (const r of definedValues(v)) { result = removeOne(result, r) diff --git a/fjs/bnf/private.ts b/fjs/bnf/private.ts new file mode 100644 index 000000000..8b047e760 --- /dev/null +++ b/fjs/bnf/private.ts @@ -0,0 +1,30 @@ +/** + * Implementation-private types for the AST renderer in `./testlib.f.mjs`. + * + * @module + */ + +import type { Ast } from './matcher/types.ts' + +/** + * The leaf of either backend's AST: `bnf/ll1` keeps the code point alone and + * `bnf/descent` pairs it with metadata, so a renderer that takes both is + * generic over exactly this. + * + * `showAst`'s exported declaration writes this union inline so the public + * declaration does not depend on this private module. + */ +export type _Leaf = number | readonly [number, unknown] + +export type _AstNode = Ast<_Leaf> + +export type _AstChild = _AstNode | _Leaf + +/** + * The renderer's accumulator: the parts already rendered, and the run of + * consumed code points being accumulated as one quoted string. + */ +export type _Parts = { + readonly parts: readonly string[] + readonly text: string +} diff --git a/fjs/bnf/testlib.f.mjs b/fjs/bnf/testlib.f.mjs index 7bb384d60..08594c06c 100644 --- a/fjs/bnf/testlib.f.mjs +++ b/fjs/bnf/testlib.f.mjs @@ -3,6 +3,7 @@ * * @import { Ast, AstTag } from './matcher/types.ts' * @import { Rule } from './types.ts' + * @import { _AstChild, _AstNode, _Leaf, _Parts } from './private.ts' */ import { codePointToString } from '../text/utf16/module.f.mjs' @@ -199,18 +200,6 @@ export const deterministic = () => { // -/** - * The leaf of either backend's AST: `bnf/ll1` keeps the code point alone and - * `bnf/descent` pairs it with metadata, so a renderer that takes both is - * generic over exactly this. - * - * @typedef {number | readonly [number, unknown]} _Leaf - */ - -/** @typedef {Ast<_Leaf>} _AstNode */ - -/** @typedef {_AstNode | _Leaf} _AstChild */ - /** * @param {_AstChild} child * @returns {child is _AstNode} @@ -230,8 +219,6 @@ const codePointOf = child => typeof child === 'number' ? child : child[0] const showTag = tag => tag === undefined ? '' : tag === true ? '*' : JSON.stringify(tag) -/** @typedef {{ readonly parts: readonly string[], readonly text: string }} _Parts */ - /** * Ends the run of consumed code points being accumulated, if there is one, so * that a node's text appears as one quoted string rather than one part per @@ -262,7 +249,11 @@ const noParts = { parts: [], text: '' } * tags survive. Repeated items are siblings under one node, whereas the * right-recursive encoding puts each item one level deeper than the last. * - * @type {(node: _AstNode) => string} + * The leaf union — a bare code point, or a code point with metadata — is + * `_Leaf` in `./private.ts`, written inline here so the exported declaration + * does not depend on the private module. + * + * @type {(node: Ast) => string} * * @example * diff --git a/fjs/cas/proof.f.mjs b/fjs/cas/proof.f.mjs index 097ef7a98..4eb24407c 100644 --- a/fjs/cas/proof.f.mjs +++ b/fjs/cas/proof.f.mjs @@ -22,8 +22,6 @@ import { assert, assertEq, assertNotNullish } from '../asserts/module.f.mjs' const testDir = './test-cas-cli' -/** @typedef {FileCasOperation | WriteFile | ReadFile | Mkdir} _TestOp */ - // Names the command a `FileCasOperation` effect stops at, so a proof can assert // on it and resume the continuation without reading the `Do` layout. The map // has to list every operation the CAS can perform — that is what makes it total, @@ -152,7 +150,7 @@ const createBigFileContent = () => { // and the virtual filesystem cannot remove a *non-empty* directory, so that // `rm` had been failing on every run without anything noticing. There is also // nothing to clean: each run interprets against a fresh `emptyState`. -/** @type {() => Effect<_TestOp, void, IoChannel>} */ +/** @type {() => Effect} */ const testAddBigFile = () => { const bigFilePath = `${testDir}/big-file.bin` const cas = fileCas(sha256)(testDir) @@ -170,7 +168,7 @@ const testAddBigFile = () => { } // Test adding and retrieving a big file -/** @type {() => Effect<_TestOp, void, IoChannel>} */ +/** @type {() => Effect} */ const testAddAndGetBigFile = () => { const bigContent = createBigFileContent() const bigFilePath = `${testDir}/big-file.bin` diff --git a/fjs/ci/todo/f-mjs-package-support.md b/fjs/ci/todo/f-mjs-package-support.md index eea1a0991..23e886d16 100644 --- a/fjs/ci/todo/f-mjs-package-support.md +++ b/fjs/ci/todo/f-mjs-package-support.md @@ -160,22 +160,15 @@ Update `AGENTS.md` with that runtime source-migration policy and the stable `types.ts` companion convention. Compiler compatibility is a later `.f.mjs -> .f.js` migration and is not part of this package prerequisite. -JSDoc declaration emit currently exposes every top-level `@typedef` as an -exported type alias. During the migration, implementation-only typedefs that stay -inside `.mjs` use the repository's leading-`_` convention, for example `_Node`; -see [`todo/migrate-typescript-to-mjs.md`](../../../todo/migrate-typescript-to-mjs.md). -An emitted `export type _Node = ...` is therefore package-private by contract, -not public API. Clean-consumer tests must exercise documented public types and -must not turn `_`-prefixed declaration artifacts into supported API merely -because TypeScript emitted them. - -Types intentionally moved to `types.ts` use ordinary TypeScript syntax and do -not need the JSDoc-emission workaround merely to remain expressible. The eventual -replacement for private JSDoc typedefs is still `@internal` plus `stripInternal`, -blocked on -[microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) -and tracked in -[`todo/blocked/jsdoc-typedef-strip-internal.md`](../../../todo/blocked/jsdoc-typedef-strip-internal.md). +Authored `.mjs` files carry no file-scope JSDoc `@typedef` (root `AGENTS.md`); +named types live in `types.ts` or an optional `private.ts`, so declaration emit +exposes private types as `_`-prefixed names in `types.d.ts` and as generated +`private.d.ts` files. Both are package-private by contract, not public API: +clean-consumer tests must exercise documented public types and must not turn +`_`-prefixed declaration artifacts into supported API merely because TypeScript +emitted them. Deleting generated `private.d.ts` before packaging is the second +stage of +[`fjs/todo/separate-private-types.md`](../../todo/separate-private-types.md). Package selection does not need to distinguish every authored `.mjs` by public API status during this transition. Incidental authored files such as @@ -248,9 +241,10 @@ emission, `npm pack`, and a clean consumer. required for portable resolution. Done in [#1520](https://github.com/functionalscript/functionalscript/pull/1520): only `types.d.ts` is required; `types.js` is no longer generated. -- [ ] Include an implementation-only `_`-prefixed JSDoc typedef in the `.mjs` - fixture; tolerate its current exported declaration form without treating it - as clean-consumer public API. +- [ ] Include an implementation-only `_`-prefixed type (in the fixture's + `types.ts` or `private.ts`, per the file-scope-typedef prohibition) whose + name reaches the emitted declarations; tolerate that declaration form + without treating it as clean-consumer public API. - [ ] Test the allowed `.ts` -> `.mjs` runtime dependency direction in a clean checkout and CI-built package archive. - [ ] Reject authored `.mjs` runtime imports to remaining relative implementation @@ -327,9 +321,9 @@ not, and the pipeline is simplified accordingly. two-pass `prepack`. - [`todo/migrate-typescript-to-mjs.md`](../../../todo/migrate-typescript-to-mjs.md) — repository-wide stage-1 implementation source migration. -- [`todo/blocked/jsdoc-typedef-strip-internal.md`](../../../todo/blocked/jsdoc-typedef-strip-internal.md) - — replace the temporary `_` convention with `@internal` when declaration emit - supports it. +- [`fjs/todo/separate-private-types.md`](../../todo/separate-private-types.md) + — private-type placement rules and the packaging stage that unships + generated private declarations. - [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) — upstream blocker for stripping private JSDoc typedefs. - [`publishing-packages.md`](./publishing-packages.md) — broader package roadmap. diff --git a/fjs/common/monoid/module.f.mjs b/fjs/common/monoid/module.f.mjs index aed4fa9d9..2bdf6ab9f 100644 --- a/fjs/common/monoid/module.f.mjs +++ b/fjs/common/monoid/module.f.mjs @@ -9,6 +9,7 @@ * @import { Fold, Reduce } from '../../types/function/operator/types.ts' * @import { Accumulator, List } from '../../types/list/types.ts' * @import { Absorbing, Monoid } from './types.ts' + * @import { _Run, _Stack } from './private.ts' */ import { fold as listFold, tryFold } from '../../types/list/module.f.mjs' @@ -59,26 +60,6 @@ export const repeat = ({ identity, operation }) => n => a => { } } -/** - * A run of `size` already-combined elements. Runs live on a stack whose top is - * the most recent — and smallest — run, so `rest` holds everything to the left - * of `value`. - * - * @template T - * @typedef {{ - * readonly size: number - * readonly value: T - * readonly rest: _Stack - * }} _Run - */ - -/** - * A stack of runs, `null` when empty. - * - * @template T - * @typedef {_Run | null} _Stack - */ - /** * Pushes a run of `size` combined elements onto the stack, merging while the * top run has the same size — exactly the carry of incrementing a binary diff --git a/fjs/common/monoid/private.ts b/fjs/common/monoid/private.ts new file mode 100644 index 000000000..ffbc1adf9 --- /dev/null +++ b/fjs/common/monoid/private.ts @@ -0,0 +1,19 @@ +/** + * Implementation-private types for the monoid fold. + * + * @module + */ + +/** + * A run of `size` already-combined elements. Runs live on a stack whose top is + * the most recent — and smallest — run, so `rest` holds everything to the left + * of `value`. + */ +export type _Run = { + readonly size: number + readonly value: T + readonly rest: _Stack +} + +/** A stack of runs, `null` when empty. */ +export type _Stack = _Run | null diff --git a/fjs/crypto/sha2/module.f.mjs b/fjs/crypto/sha2/module.f.mjs index e08474495..2094d2569 100644 --- a/fjs/crypto/sha2/module.f.mjs +++ b/fjs/crypto/sha2/module.f.mjs @@ -28,25 +28,17 @@ const { concat, front } = msb // across every `base(...)` config (32-bit and 64-bit SHA-2 variants). const chunkListMsb = chunkList(msb) -/** @typedef {Tuple<3, bigint>} _V3 */ - -/** @typedef {Tuple<4, bigint>} _V4 */ - -/** - * @typedef {{ - * readonly logBitLen: bigint, - * readonly k: readonly V16[], - * readonly bs0: _V3, - * readonly bs1: _V3, - * readonly ss0: _V3, - * readonly ss1: _V3, - * }} _BaseInit - */ - /** @type {Vec} */ const lastOne = vec(1n)(1n) -/** @type {(init: _BaseInit) => Base} */ +/** @type {(init: { + * readonly logBitLen: bigint, + * readonly k: readonly V16[], + * readonly bs0: Tuple<3, bigint>, + * readonly bs1: Tuple<3, bigint>, + * readonly ss0: Tuple<3, bigint>, + * readonly ss1: Tuple<3, bigint>, + * }) => Base} */ const base = ({ logBitLen, k, bs0, bs1, ss0, ss1 }) => { const bitLength = 1n << logBitLen @@ -57,7 +49,7 @@ const base = ({ logBitLen, k, bs0, bs1, ss0, ss1 }) => { return n => n >> d | n << r } - /** @type {(third: Reduce) => (..._: _V3) => (x: bigint) => bigint} */ + /** @type {(third: Reduce) => (..._: Tuple<3, bigint>) => (x: bigint) => bigint} */ const sigma = third => (a, b, c) => { const ra = rotr(a) const rb = rotr(b) @@ -85,7 +77,7 @@ const base = ({ logBitLen, k, bs0, bs1, ss0, ss1 }) => { const m = mask(bitLength) - /** @type {(..._: _V4) => bigint} */ + /** @type {(..._: Tuple<4, bigint>) => bigint} */ const wi = (a0, a1, a2, a3) => (smallSigma1(a0) + a1 + smallSigma0(a2) + a3) & m diff --git a/fjs/crypto/sign/module.f.mjs b/fjs/crypto/sign/module.f.mjs index b0afe15c1..6384dec46 100644 --- a/fjs/crypto/sign/module.f.mjs +++ b/fjs/crypto/sign/module.f.mjs @@ -8,7 +8,7 @@ * @import { Vec } from '../../types/bit_vec/types.ts' * @import { Curve } from '../secp/types.ts' * @import { Sha2 } from '../sha2/types.ts' - * @import { All } from './types.ts' + * @import { All, _Signature } from './types.ts' */ import { assertNotNullish } from '../../asserts/module.f.mjs' @@ -133,8 +133,6 @@ export const computeK = } } -/** @typedef {Tuple<2, bigint>} _Signature */ - /** * Signs a message bit vector and returns an ECDSA `(r, s)` signature pair. * diff --git a/fjs/crypto/sign/types.ts b/fjs/crypto/sign/types.ts index 9825e3666..22f23da2c 100644 --- a/fjs/crypto/sign/types.ts +++ b/fjs/crypto/sign/types.ts @@ -4,6 +4,7 @@ * @module */ +import type { Tuple } from '../../types/array/types.ts' import type { Vec } from '../../types/bit_vec/types.ts' export type All = { @@ -13,3 +14,6 @@ export type All = { readonly int2octets: (x: bigint) => Vec readonly bits2octets: (b: Vec) => Vec } + +/** An ECDSA signature: the `(r, s)` pair. */ +export type _Signature = Tuple<2, bigint> diff --git a/fjs/djs/ast/module.f.mjs b/fjs/djs/ast/module.f.mjs index 04e87c945..f6953cbf0 100644 --- a/fjs/djs/ast/module.f.mjs +++ b/fjs/djs/ast/module.f.mjs @@ -4,9 +4,8 @@ * @module * * @import { Array, Unknown } from '../types.ts' - * @import { List } from '../../types/list/types.ts' - * @import { Entry } from '../../types/ordered_map/types.ts' * @import { AstConst, AstBody } from './types.ts' + * @import { _FoldObjectState, _RunState } from './private.ts' */ import { concat, fold, last, map, take, toArray } from '../../types/list/module.f.mjs' @@ -14,17 +13,6 @@ import { fromEntries } from '../../types/object/module.f.mjs' const { entries } = Object -/** @typedef {{ - * readonly body: AstBody - * readonly args: Array - * readonly consts: List - * }} _RunState */ - -/** @typedef {{ - * readonly runState: _RunState, - * readonly entries: List> - * }} _FoldObjectState */ - /** @type {(ast: AstConst) => (state: _RunState) => _RunState} */ const foldOp = ast => state => { const djs = toDjs(state)(ast) diff --git a/fjs/djs/ast/private.ts b/fjs/djs/ast/private.ts new file mode 100644 index 000000000..68b28227a --- /dev/null +++ b/fjs/djs/ast/private.ts @@ -0,0 +1,23 @@ +/** + * Implementation-private types for the DJS AST evaluator. + * + * @module + */ + +import type { List } from '../../types/list/types.ts' +import type { Entry } from '../../types/ordered_map/types.ts' +import type { Array, Unknown } from '../types.ts' +import type { AstBody } from './types.ts' + +/** An evaluation in progress: the body, its arguments, and the values so far. */ +export type _RunState = { + readonly body: AstBody + readonly args: Array + readonly consts: List +} + +/** The state of folding an AST object's entries into evaluated entries. */ +export type _FoldObjectState = { + readonly runState: _RunState, + readonly entries: List> +} diff --git a/fjs/djs/module.f.mjs b/fjs/djs/module.f.mjs index 6716d28e3..6256873a5 100644 --- a/fjs/djs/module.f.mjs +++ b/fjs/djs/module.f.mjs @@ -3,9 +3,8 @@ * * @module * - * @import { WriteFile, ReadFile, Write } from '../effects/node/types.ts' * @import { Result } from '../types/result/types.ts' - * @import { Unknown } from './types.ts' + * @import { Unknown, _CompileOp } from './types.ts' * @import { ParseError } from './parser/types.ts' * @import { Effect } from '../effects/types.ts' */ @@ -16,8 +15,6 @@ import { sort } from '../types/object/module.f.mjs' import { resultStep } from '../effects/module.f.mjs' import { errorExit, exitStep, writeUtf8File } from '../effects/node/module.f.mjs' -/** @typedef {ReadFile | WriteFile | Write} _CompileOp */ - /** * Where an error happened, as much of it as is known: the token's * `path:line:column` when the reader tracks positions, and otherwise the name diff --git a/fjs/djs/parser/module.f.mjs b/fjs/djs/parser/module.f.mjs index 4fe10a1f0..6090981b2 100644 --- a/fjs/djs/parser/module.f.mjs +++ b/fjs/djs/parser/module.f.mjs @@ -5,18 +5,14 @@ * * @import { Result } from '../../types/result/types.ts' * @import { List } from '../../types/list/types.ts' - * @import { Fold } from '../../types/function/operator/types.ts' * @import { DjsToken, DjsTokenWithMetadata } from '../tokenizer/types.ts' - * @import { OrderedMap } from '../../types/ordered_map/types.ts' * @import { AstArray, AstConst, AstModule, AstModuleRef, AstObject } from '../ast/types.ts' - * @import { TokenMetadata } from '../../js/tokenizer/types.ts' - * @import { ParseError, _FramingKeyword, _OrdinaryTokenName, _ValueToken } from './types.ts' - * @import { Assert } from '../../asserts/types.ts' - * @import { Equal } from '../../types/ts/types.ts' + * @import { ParseError, _OrdinaryTokenName, _ValueToken } from './types.ts' * @import { CodePointMeta } from '../../bnf/descent/types.ts' - * @import { Ast, AstSequence } from '../../bnf/matcher/types.ts' + * @import { AstSequence } from '../../bnf/matcher/types.ts' * @import { Rule, TerminalRange } from '../../bnf/types.ts' * @import { DescentMatch } from '../../bnf/descent/types.ts' + * @import { _FoldFrame, _FoldState, _Node, _TokenStream } from './private.ts' */ import { error, ok } from '../../types/result/module.f.mjs' @@ -29,16 +25,6 @@ import { encoding } from '../../bnf/token_symbol/module.f.mjs' import { toData } from '../../bnf/data/module.f.mjs' import { descentParserRuleSet } from '../../bnf/descent/module.f.mjs' -/** - * The ordinary token stream a BNF parser layer consumes, with the tokenizer's - * one physical end-of-input token split off. - * - * @typedef {{ - * readonly tokens: readonly DjsTokenWithMetadata[] - * readonly eofMetadata: TokenMetadata - * }} _TokenStream - */ - /** * Splits the tokenizer's single final physical `eof` token off a token list. * @@ -96,13 +82,14 @@ const splitEof = tokenList => { * * A name is not always a kind. The framing keywords arrive as `id` tokens and * need terminals of their own, or the grammar could not tell `export default` - * from two arbitrary identifiers — see {@link framingKeywords}. + * from two arbitrary identifiers — see {@link _framingKeywords}. * - * The `_…AreComplete` assertions below check both halves against `DjsToken` and - * `_FramingKeyword` at compile time, so a kind or keyword added there breaks the - * build rather than going unrepresented. + * The `_…AreComplete` assertions in `./proof.f.mjs`'s `consistency` entry check + * both halves against `DjsToken` and `_FramingKeyword` at compile time, so a + * kind or keyword added there breaks the build rather than going unrepresented. + * Exported with a leading `_` for that linkage — the export is not API. */ -const tokenKindNames = /** @type {const} */ ([ +export const _tokenKindNames = /** @type {const} */ ([ 'true', 'false', 'null', 'undefined', '{', '}', ':', ',', '[', ']', '.', '=', 'string', 'number', 'error', 'id', 'bigint', @@ -125,29 +112,14 @@ const tokenKindNames = /** @type {const} */ ([ * Giving a word its own symbol narrows where it is *required*, never where it is * *allowed*. */ -const framingKeywords = /** @type {const} */ (['import', 'const', 'export', 'default', 'from']) +export const _framingKeywords = /** @type {const} */ (['import', 'const', 'export', 'default', 'from']) /** * The complete alphabet: one name per `DjsToken` kind except `eof`, plus one per * framing keyword. No keyword collides with a kind, so the two lists concatenate * without a name being registered twice — which `encoding` would reject anyway. */ -const ordinaryTokenNames = [...tokenKindNames, ...framingKeywords] - -/** @typedef {Assert>>} _KindsAreComplete */ - -/** @typedef {Assert>} _KeywordsAreComplete */ - -/** @typedef {Assert>} _AlphabetIsComplete */ - -/** - * `eof` is not a member of the alphabet, so a second end marker cannot be - * encoded rather than merely going unused — and `encode` would reject the name - * outright. Checked at the type level because that is where it is decidable: - * `includes('eof')` does not even compile against this element type. - * - * @typedef {Assert, never>>} _EofIsNotAName - */ +export const _ordinaryTokenNames = [..._tokenKindNames, ..._framingKeywords] /** * The alphabet's encoding, built once for the module rather than per parse. @@ -158,7 +130,7 @@ const ordinaryTokenNames = [...tokenKindNames, ...framingKeywords] * last Unicode scalar value, so a token symbol can never be mistaken for a code * point of the layer below. */ -const tokenEncoding = encoding(ordinaryTokenNames) +const tokenEncoding = encoding(_ordinaryTokenNames) /** * One ordinary token as a descent input leaf: the symbol standing for its kind, @@ -181,7 +153,7 @@ const tokenToSymbol = t => { // a set membership test because it also narrows the result to the keyword // union, which is what lets `encode` be called without a cast. const keyword = token.kind === 'id' - ? framingKeywords.find(k => k === token.value) + ? _framingKeywords.find(k => k === token.value) : undefined const name = keyword ?? token.kind assert(name !== 'eof', ['eof token reached the parser alphabet', t]) @@ -226,7 +198,7 @@ const statementEnd = () => [ * Every word that may stand where an identifier is expected: a plain `id` and * each framing keyword, since none of them is reserved. * - * This is the union {@link framingKeywords} obliges the grammar to provide. + * This is the union {@link _framingKeywords} obliges the grammar to provide. */ const identifier = { id: sym('id'), @@ -396,8 +368,6 @@ const isValueToken = token => { } // -- folding the match into an `AstModule` ---------------------------------- -/** @typedef {Ast>} _Node */ - /** * The token a slot holds. * @@ -509,22 +479,6 @@ const keyOf = node => { return [token.value, computed] } -/** - * A fold in progress: the names bound so far, the module specifiers and the - * body collected so far, and the first error if one has been met. - * - * The error rides in the state rather than wrapping every step in a `Result`, - * so a step reads as one expression instead of a nested match. Once set it is - * never replaced, which is what makes the reported error the *first* one. - * - * @typedef {{ - * readonly refs: OrderedMap - * readonly modules: readonly string[] - * readonly consts: readonly AstConst[] - * readonly error: ParseError | null - * }} _FoldState - */ - /** @type {(message: string) => (token: DjsTokenWithMetadata) => ParseError} */ const foldError = message => ({ metadata }) => ({ message, metadata }) @@ -545,24 +499,6 @@ const bind = state => node => ref => { : { ...state, refs: setReplace(token.value)(ref)(state.refs) } } -/** - * A frame of {@link foldValue}'s explicit stack: the container being built, the - * element nodes still to read, and what has been built so far. - * - * `done` is a `List` rather than an array because a frame gains one element at a - * time: appending to an array per element would copy the whole prefix each time, - * which is what makes the obvious spelling quadratic in an array's length. - * - * @typedef {{ - * readonly items: readonly _Node[] - * readonly index: number - * readonly array: List - * readonly object: OrderedMap - * readonly keys: readonly(readonly[string, boolean])[] - * readonly isArray: boolean - * }} _FoldFrame - */ - /** * The error a frame's current key earns, or `null`. * @@ -774,7 +710,7 @@ export const proof = { // matters because the token-symbol mapping this alphabet feeds has to be // injective over it — two entries for one name would break that. noDuplicates: () => { - assertEq(new Set(ordinaryTokenNames).size, ordinaryTokenNames.length) + assertEq(new Set(_ordinaryTokenNames).size, _ordinaryTokenNames.length) }, }, tokenToSymbol: { @@ -782,8 +718,8 @@ export const proof = { // code point — the three properties that let a token stream be the // alphabet of the layer above. distinctAndAboveUnicode: () => { - const symbols = ordinaryTokenNames.map(n => tokenEncoding.encode(n)) - assertEq(new Set(symbols).size, ordinaryTokenNames.length) + const symbols = _ordinaryTokenNames.map(n => tokenEncoding.encode(n)) + assertEq(new Set(symbols).size, _ordinaryTokenNames.length) const [, unicodeLast] = rangeDecode(unicodeRange) assert(symbols.every(s => s > unicodeLast), JSON.stringify(symbols)) }, @@ -796,9 +732,9 @@ export const proof = { const symbolOf = value => tokenToSymbol({ token: { kind: 'id', value }, metadata: { path: 'a.js', line: 1, column: 1 } })[0] const id = symbolOf('foo') - const keywords = framingKeywords.map(symbolOf) + const keywords = _framingKeywords.map(symbolOf) assert(keywords.every(s => s !== id), JSON.stringify([id, keywords])) - assertEq(new Set(keywords).size, framingKeywords.length) + assertEq(new Set(keywords).size, _framingKeywords.length) assertEq(tokenEncoding.decode(symbolOf('export')), 'export') assertEq(tokenEncoding.decode(id), 'id') }, diff --git a/fjs/djs/parser/private.ts b/fjs/djs/parser/private.ts new file mode 100644 index 000000000..7649cb761 --- /dev/null +++ b/fjs/djs/parser/private.ts @@ -0,0 +1,58 @@ +/** + * Implementation-private types for the DJS parser. + * + * @module + */ + +import type { CodePointMeta } from '../../bnf/descent/types.ts' +import type { Ast } from '../../bnf/matcher/types.ts' +import type { TokenMetadata } from '../../js/tokenizer/types.ts' +import type { List } from '../../types/list/types.ts' +import type { OrderedMap } from '../../types/ordered_map/types.ts' +import type { AstConst, AstModuleRef } from '../ast/types.ts' +import type { DjsTokenWithMetadata } from '../tokenizer/types.ts' +import type { ParseError } from './types.ts' + +/** + * The ordinary token stream a BNF parser layer consumes, with the tokenizer's + * one physical end-of-input token split off. + */ +export type _TokenStream = { + readonly tokens: readonly DjsTokenWithMetadata[] + readonly eofMetadata: TokenMetadata +} + +/** A node of the matched module's AST, its leaves carrying the tokens. */ +export type _Node = Ast> + +/** + * A fold in progress: the names bound so far, the module specifiers and the + * body collected so far, and the first error if one has been met. + * + * The error rides in the state rather than wrapping every step in a `Result`, + * so a step reads as one expression instead of a nested match. Once set it is + * never replaced, which is what makes the reported error the *first* one. + */ +export type _FoldState = { + readonly refs: OrderedMap + readonly modules: readonly string[] + readonly consts: readonly AstConst[] + readonly error: ParseError | null +} + +/** + * A frame of `foldValue`'s explicit stack: the container being built, the + * element nodes still to read, and what has been built so far. + * + * `done` is a `List` rather than an array because a frame gains one element at a + * time: appending to an array per element would copy the whole prefix each time, + * which is what makes the obvious spelling quadratic in an array's length. + */ +export type _FoldFrame = { + readonly items: readonly _Node[] + readonly index: number + readonly array: List + readonly object: OrderedMap + readonly keys: readonly(readonly[string, boolean])[] + readonly isArray: boolean +} diff --git a/fjs/djs/parser/proof.f.mjs b/fjs/djs/parser/proof.f.mjs index 54e59831a..29859ff27 100644 --- a/fjs/djs/parser/proof.f.mjs +++ b/fjs/djs/parser/proof.f.mjs @@ -1,8 +1,16 @@ /** - * @import { DjsTokenWithMetadata } from '../tokenizer/types.ts' + * @import { Assert } from '../../asserts/types.ts' + * @import { Equal } from '../../types/ts/types.ts' + * @import { DjsToken, DjsTokenWithMetadata } from '../tokenizer/types.ts' + * @import { _FramingKeyword, _OrdinaryTokenName } from './types.ts' */ -import { parseFromTokens } from './module.f.mjs' +import { + parseFromTokens, + _framingKeywords, + _ordinaryTokenNames, + _tokenKindNames, +} from './module.f.mjs' import { tokenize } from '../tokenizer/module.f.mjs' import { toArray } from '../../types/list/module.f.mjs' import { sort } from '../../types/object/module.f.mjs' @@ -44,6 +52,22 @@ const proofKind = (kind, line) => ({ token: { kind }, metadata: { path: 'a.js', const proofId = (value, line) => ({ token: { kind: 'id', value }, metadata: { path: 'a.js', line, column: 1 } }) export const proof = { + /** + * The parser alphabet in `./module.f.mjs` agrees with its type-level + * description in `./types.ts`. These are compile-time checks; the function + * body only has to exist so the typedefs have a local scope. + */ + consistency: () => { + /** @typedef {Assert>>} _KindsAreComplete */ + /** @typedef {Assert>} _KeywordsAreComplete */ + /** @typedef {Assert>} _AlphabetIsComplete */ + // `eof` is not a member of the alphabet, so a second end marker cannot + // be encoded rather than merely going unused — and `encode` would + // reject the name outright. Checked at the type level because that is + // where it is decidable: `includes('eof')` does not even compile + // against this element type. + /** @typedef {Assert, never>>} _EofIsNotAName */ + }, // The corpus that proved parity against the hand-written state machine, // kept as fixed expectations now that the state machine is gone. // diff --git a/fjs/djs/serializer/module.f.mjs b/fjs/djs/serializer/module.f.mjs index 0e8e0bc71..cecf5e460 100644 --- a/fjs/djs/serializer/module.f.mjs +++ b/fjs/djs/serializer/module.f.mjs @@ -10,6 +10,8 @@ * @import { Unknown, Object, _MapEntries } from '../types.ts' * @import { Fold } from '../../types/function/operator/types.ts' * @import { List } from '../../types/list/types.ts' + * @import { _RefCounter, _Refs } from './types.ts' + * @import { _KeySerialize, _RefLookup } from './private.ts' */ import { fold } from '../../types/list/module.f.mjs' @@ -23,10 +25,6 @@ import { assertNotNullish } from '../../asserts/module.f.mjs' export const undefinedSerialize = ['undefined'] -/** @typedef {readonly [number, number]} _RefCounter */ - -/** @typedef {ReadonlyMap} _Refs */ - /** * Returns the value's `RefCounter` only if it is *shared* (referenced more * than once) — otherwise `undefined`. Names the single predicate that drives @@ -39,13 +37,12 @@ const sharedRef = refs => v => { return rc !== undefined && rc[1] > 1 ? rc : undefined } -/** @typedef {{ - * readonly added: ReadonlySet - * readonly consts: List - * }} _GetConstsState */ - /** @type {(refs: _Refs) => (djs: Unknown) => List} */ const getConstants = refs => { + /** @typedef {{ + * readonly added: ReadonlySet + * readonly consts: List + * }} _GetConstsState */ const shared = sharedRef(refs) /** @type {Fold} */ const checkSelf = djs => state => { @@ -82,24 +79,9 @@ const getConstants = refs => { /** @type {(kv: readonly [string, Unknown]) => Unknown} */ const entryValue = kv => kv[1] -/** - * A pre-hook consulted before each value's default serialization. - * Returning a non-null list short-circuits the default path; this is how - * `serializeWithConst` substitutes repeated values with `c` references. - * @typedef {(value: Unknown) => List | null} _RefLookup - */ - /** @type {_RefLookup} */ const noRef = () => null -/** - * How one output format spells a property key. The two formats disagree about - * exactly one key, `__proto__`, so the spelling is a parameter of - * `buildSerialize` rather than a property of the shared JSON helper. - * - * @typedef {(key: string) => List} _KeySerialize - */ - const protoKey = '__proto__' /** diff --git a/fjs/djs/serializer/private.ts b/fjs/djs/serializer/private.ts new file mode 100644 index 000000000..a461610dd --- /dev/null +++ b/fjs/djs/serializer/private.ts @@ -0,0 +1,22 @@ +/** + * Implementation-private types for the DJS serializer. + * + * @module + */ + +import type { List } from '../../types/list/types.ts' +import type { Unknown } from '../types.ts' + +/** + * A pre-hook consulted before each value's default serialization. + * Returning a non-null list short-circuits the default path; this is how + * `serializeWithConst` substitutes repeated values with `c` references. + */ +export type _RefLookup = (value: Unknown) => List | null + +/** + * How one output format spells a property key. The two formats disagree about + * exactly one key, `__proto__`, so the spelling is a parameter of + * `buildSerialize` rather than a property of the shared JSON helper. + */ +export type _KeySerialize = (key: string) => List diff --git a/fjs/djs/serializer/types.ts b/fjs/djs/serializer/types.ts new file mode 100644 index 000000000..d58e2bb0c --- /dev/null +++ b/fjs/djs/serializer/types.ts @@ -0,0 +1,14 @@ +/** + * Type-level API for `fjs/djs/serializer/module.f.mjs`: the reference-count + * map `countRefs` produces and `stringify` hoists `const`s from. + * + * @module + */ + +import type { Unknown } from '../types.ts' + +/** A value's `const` index and how many times the value is referenced. */ +export type _RefCounter = readonly [number, number] + +/** Every value of a graph, mapped to its {@link _RefCounter}. */ +export type _Refs = ReadonlyMap diff --git a/fjs/djs/tokenizer/module.f.mjs b/fjs/djs/tokenizer/module.f.mjs index 1d628563f..ee9675352 100644 --- a/fjs/djs/tokenizer/module.f.mjs +++ b/fjs/djs/tokenizer/module.f.mjs @@ -11,18 +11,9 @@ * } from '../../bnf/descent/types.ts' * @import { DataRule, Rule } from '../../bnf/types.ts' * @import { - * BigIntToken, - * CommentToken, - * EofToken, - * ErrorToken, - * IdToken, * JsToken, * JsTokenWithMetadata, - * NewLineToken, - * NumberToken, - * StringToken, * TokenMetadata, - * WhitespaceToken, * } from '../../js/tokenizer/types.ts' * @import { CodePoint } from '../../text/utf16/types.ts' * @import { StateScan } from '../../types/function/operator/types.ts' @@ -30,6 +21,13 @@ * @import { DjsToken, DjsTokenWithMetadata } from './types.ts' * @import { TriviaKind } from '../../js/tokenizer/types.ts' * @import { Nullable } from '../../types/nullable/types.ts' + * @import { + * _DjsScanState, + * _FlatToken, + * _StringDecodeState, + * _Token, + * _TokenScanState, + * } from './private.ts' */ import { assert, assertEq } from '../../asserts/module.f.mjs' @@ -314,13 +312,6 @@ const metadataScan = (cp, metadata) => [[[cp, metadata]], advanceMetadata(cp)(me /** @type {(path: string) => (cp: readonly number[]) => readonly CodePointMeta[]} */ const codePointsWithMetadata = path => cp => toArray(flat(stateScan(metadataScan)({ path, line: 1, column: 1 })(cp))) -// tag, the metadata of the token's first code point, and its code points. -/** @typedef {[string, TokenMetadata, readonly number[]]} _Token */ - -/** @typedef {string | CodePointMeta} _FlatToken */ - -/** @typedef {[string, TokenMetadata | null, List]} _TokenScanState */ - /** * The grammar tag of a trivia code point, as the kind `mergeTrivia` speaks in; * `null` for every other tag. @@ -396,12 +387,6 @@ const filterFunc = tk => { */ const unwrapHexDigitValue = mapUnwrap(hexDigitValue) -/** @typedef { - * | { readonly kind: 'normal' } - * | { readonly kind: 'escape' } - * | { readonly kind: 'unicode', readonly acc: number, readonly count: number } - * } _StringDecodeState */ - /** @type {StateScan>} */ const stringDecodeScan = (cp, state) => { switch (state.kind) { @@ -592,8 +577,6 @@ export const tokenizeJs = input => path => { return withMetadata([{ token: { kind: 'eof' }, metadata: finalMetadata }]) } -/** @typedef {{ readonly kind: 'def' | '-' }} _DjsScanState */ - /** @type {(input: JsToken) => List} */ const mapDjsToken = input => { switch (input.kind) { diff --git a/fjs/djs/tokenizer/private.ts b/fjs/djs/tokenizer/private.ts new file mode 100644 index 000000000..b836d4878 --- /dev/null +++ b/fjs/djs/tokenizer/private.ts @@ -0,0 +1,27 @@ +/** + * Implementation-private types for the DJS tokenizer. + * + * @module + */ + +import type { CodePointMeta } from '../../bnf/descent/types.ts' +import type { TokenMetadata } from '../../js/tokenizer/types.ts' +import type { List } from '../../types/list/types.ts' + +/** A tag, the metadata of the token's first code point, and its code points. */ +export type _Token = [string, TokenMetadata, readonly number[]] + +/** One item of a flattened match: a tag, or a code point with its metadata. */ +export type _FlatToken = string | CodePointMeta + +/** A token being accumulated: its tag, start metadata, and code points so far. */ +export type _TokenScanState = [string, TokenMetadata | null, List] + +/** Where a string-literal decode is: plain text, after `\`, or inside `\uXXXX`. */ +export type _StringDecodeState = + | { readonly kind: 'normal' } + | { readonly kind: 'escape' } + | { readonly kind: 'unicode', readonly acc: number, readonly count: number } + +/** Whether the previous JS token was a bare `-` awaiting a number to negate. */ +export type _DjsScanState = { readonly kind: 'def' | '-' } diff --git a/fjs/djs/types.ts b/fjs/djs/types.ts index 1ba892335..0a7845fd1 100644 --- a/fjs/djs/types.ts +++ b/fjs/djs/types.ts @@ -12,6 +12,7 @@ import type { } from '../media/json/types.ts' import type { Assert } from '../asserts/types.ts' import type { Equal } from '../types/ts/types.ts' +import type { ReadFile, Write, WriteFile } from '../effects/node/types.ts' export type Object = { readonly[k in string]?: Unknown } @@ -35,3 +36,6 @@ type _Unknown = Assert>> * extended JSON instantiate, at DJS's leaf set. */ export type _MapEntries = TreeMapEntries + +/** The effect operations `compile` performs: file I/O and error output. */ +export type _CompileOp = ReadFile | WriteFile | Write diff --git a/fjs/edag/amnesia/module.f.mjs b/fjs/edag/amnesia/module.f.mjs index 8cac12387..4fe59c341 100644 --- a/fjs/edag/amnesia/module.f.mjs +++ b/fjs/edag/amnesia/module.f.mjs @@ -37,15 +37,11 @@ const o2 = (/**@type {(a: any, b: any) => unknown}*/o) => o2lazy((a, b) => o(a, b())) -/** @typedef {(c: Context, e: Op1) => unknown} _Func1 */ - const o1 = (/**@type {(a: any) => unknown}*/o) => - /**@type {_Func1}*/ + /**@type {(c: Context, e: Op1) => unknown}*/ (c, [, a]) => o(vm(c)(a)) -/** @typedef {(_: Exp) => unknown} _Eval */ - /** Both ways of being nullish, which is what every optional step guards. */ /** @type {(v: unknown) => boolean} */ const nullish = v => v === undefined || v === null @@ -56,7 +52,7 @@ const nullish = v => v === undefined || v === null * collects with `(...args)`. Passed as a single argument instead, the callee's * `['args']` would be `[[a, b]]`. * - * @type {(f: _Eval, e: Exp) => readonly any[]} + * @type {(f: (_: Exp) => unknown, e: Exp) => readonly any[]} */ const argsOf = (f, e) => /**@type {any}*/(f(e)) @@ -69,7 +65,7 @@ const argsOf = (f, e) => /**@type {any}*/(f(e)) * method would then silently succeed on the wrapper instead of throwing: * `((a.at)(0))(0)` returned `Array.prototype.at`. * - * @type {(f: _Eval, v: unknown, e: Exp) => unknown} + * @type {(f: (_: Exp) => unknown, v: unknown, e: Exp) => unknown} */ const callValue = (f, v, e) => /**@type {any}*/(v)(...argsOf(f, e)) @@ -90,7 +86,7 @@ const callValue = (f, v, e) => /**@type {any}*/(v)(...argsOf(f, e)) * would put every argument list ahead of the property read, and every test * here would still pass. * - * @type {(f: _Eval, obj: any, prop: any, e: Exp) => unknown} + * @type {(f: (_: Exp) => unknown, obj: any, prop: any, e: Exp) => unknown} */ const callProperty = (f, obj, prop, e) => obj[prop](...argsOf(f, e)) @@ -106,7 +102,7 @@ const callProperty = (f, obj, prop, e) => obj[prop](...argsOf(f, e)) * every step is `[tag, operand, continuation]`, and a `|!()` is reachable * through `|.` steps from either — `(a?.(...b).c)(...d)` is exactly that. * - * @type {(f: _Eval, k: OptionLambda | OptionPropertyLambda) => unknown} + * @type {(f: (_: Exp) => unknown, k: OptionLambda | OptionPropertyLambda) => unknown} */ const skip = (f, k) => { if (k === null) { return undefined } @@ -120,7 +116,7 @@ const skip = (f, k) => { * step leaves. Nothing here can short-circuit: the two productions are a call * that stays in the region and a property access that hands on a receiver. * - * @type {(f: _Eval, v: unknown, k: OptionLambda) => unknown} + * @type {(f: (_: Exp) => unknown, v: unknown, k: OptionLambda) => unknown} */ const optionLambda = (f, v, k) => { if (k === null) { return v } @@ -141,7 +137,7 @@ const optionLambda = (f, v, k) => { * `obj[prop]` is read once per step, twice only where the guard has to see * the value before the call is made. * - * @type {(f: _Eval, obj: any, prop: any, k: OptionPropertyLambda) => unknown} + * @type {(f: (_: Exp) => unknown, obj: any, prop: any, k: OptionPropertyLambda) => unknown} */ const optionPropertyLambda = (f, obj, prop, k) => { if (k === null) { return obj[prop] } @@ -164,7 +160,7 @@ const optionPropertyLambda = (f, obj, prop, k) => { * node's value, since `optionLambda` has no `|!()` of its own — but the walk * still goes through `skip`, which reaches one through a `|.`. * - * @type {(f: _Eval, obj: any, prop: any, k: PropertyLambda) => unknown} + * @type {(f: (_: Exp) => unknown, obj: any, prop: any, k: PropertyLambda) => unknown} */ const propertyLambda = (f, obj, prop, k) => { if (k === null) { return obj[prop] } diff --git a/fjs/edag/amnesia/proof.f.mjs b/fjs/edag/amnesia/proof.f.mjs index 26d625e08..9fde723ef 100644 --- a/fjs/edag/amnesia/proof.f.mjs +++ b/fjs/edag/amnesia/proof.f.mjs @@ -17,15 +17,6 @@ import { assert, assertEq, assertStructurallySame } from '../../asserts/module.f.mjs' import { vm } from './module.f.mjs' -// `TagMap` exists so a dispatcher generic over `K` sees one handler -// signature; these pin the tag -> node-tuple correlation it is built on, -// including the tags whose node kinds are not `op1`/`op2`. -/** @typedef {Assert, Op2>>} _PlusIsOp2 */ -/** @typedef {Assert, Op1>>} _NegIsOp1 */ -/** @typedef {Assert, ExpArray>>} _BracketsIsArray */ -/** @typedef {Assert, Call>>} _CallIsCall */ -/** @typedef {Assert, Dot>>} _DotIsDot */ - /** @type {Context} */ const context = { frame: { x: 1 }, args: [10, 20] } @@ -96,6 +87,16 @@ const methods = ['{}', [ const constMethods = ['=>', ['[]', []], methods] export const proof = { + // `TagMap` exists so a dispatcher generic over `K` sees one handler + // signature; these pin the tag -> node-tuple correlation it is built on, + // including the tags whose node kinds are not `op1`/`op2`. + tagMap: () => { + /** @typedef {Assert, Op2>>} _PlusIsOp2 */ + /** @typedef {Assert, Op1>>} _NegIsOp1 */ + /** @typedef {Assert, ExpArray>>} _BracketsIsArray */ + /** @typedef {Assert, Call>>} _CallIsCall */ + /** @typedef {Assert, Dot>>} _DotIsDot */ + }, // The non-`Array` side of `vm`'s only branch: a primitive is its own // value, returned without ever reaching `map`. primitive: () => { diff --git a/fjs/edag/module.f.mjs b/fjs/edag/module.f.mjs index f742bb826..114378d95 100644 --- a/fjs/edag/module.f.mjs +++ b/fjs/edag/module.f.mjs @@ -1,34 +1,7 @@ /** * @module * - * @import { Assert } from '../asserts/types.ts' - * @import { Check, Check3 } from '../rtti/ts/types.ts' - * @import { - * Array, - * Exp, - * Primitive, - * Property, - * NumberCast, - * Object, - * PropertyLambda, - * OptionLambda, - * OptionPropertyLambda, - * Call, - * Dot, - * OptionDot, - * OptionCall, - * Comma, - * Op2Id, - * Op2, - * Op1Id, - * Op1, - * Op0Id, - * Op0, - * Spread, - * Items, - * Properties, - * Exps, - * } from './types.ts' + * @import { Exp, OptionLambda, OptionPropertyLambda } from './types.ts' * @import { Phantom } from '../types/phantom/types.ts' */ @@ -79,7 +52,7 @@ import { * typeof op0, * ]} */ -const _exp = () => (['or', +export const _exp = () => (['or', primitive, array, object, @@ -96,8 +69,6 @@ const _exp = () => (['or', /** @type {Phantom} */ export const exp = _exp -/** @typedef {Assert>} _ExpAssert */ - // Primitive /** @@ -110,14 +81,10 @@ export const exp = _exp */ export const primitive = or(null, boolean, number, string, bigint) -/** @typedef {Assert>} _Primitive */ - // Exps export const exps = rttiArray(exp) -/** @typedef {Assert>} _Exps */ - // Spread /** @@ -131,15 +98,11 @@ export const exps = rttiArray(exp) */ export const spread = /** @type {const} */ (['...', exp]) -/** @typedef {Assert>} _Spread */ - // Items /** An array element: a plain `exp`, or a `spread` splicing another array in. */ export const items = or(exp, spread) -/** @typedef {Assert>} _Items */ - // Array /** @@ -150,8 +113,6 @@ export const items = or(exp, spread) */ export const array = /** @type {const} */ (['[]', rttiArray(items)]) -/** @typedef {Assert>} _Array */ - // Property /** @@ -167,15 +128,11 @@ export const array = /** @type {const} */ (['[]', rttiArray(items)]) */ export const property = /** @type {const} */ ([':', exp, exp]) -/** @typedef {Assert>} _Property */ - // Properties /** An object entry: a plain `property`, or a `spread` splicing another object in. */ export const properties = or(property, spread) -/** @typedef {Assert>} _Properties */ - // Object — same nesting as `array` above, one position further in /** @@ -203,8 +160,6 @@ export const properties = or(property, spread) */ export const object = /** @type {const} */ (['{}', rttiArray(properties)]) -/** @typedef {Assert>} _Object */ - // Number /** @@ -214,10 +169,6 @@ export const object = /** @type {const} */ (['{}', rttiArray(properties)]) */ export const numberCast = /** @type {const} */ (['Number', exp]) -/** - * @typedef {Assert>} _NumberCast - */ - // Index /** @@ -299,7 +250,7 @@ export const index = or(numberCast, string, number) * readonly['|.', typeof index, typeof optionPropertyLambda], * ]} */ -const _optionLambda = () => (['or', +export const _optionLambda = () => (['or', null, /** @type {const} */ (['|()', exp, optionLambda]), /** @type {const} */ (['|.', index, optionPropertyLambda]), @@ -308,10 +259,6 @@ const _optionLambda = () => (['or', /** @type {Phantom} */ export const optionLambda = _optionLambda -/** - * @typedef {Assert>} _OptionLambda - */ - /** * The continuation of a property step **inside** an open region — both bits * live, so this is the state with every production. @@ -339,7 +286,7 @@ export const optionLambda = _optionLambda * readonly['|!()', typeof exp, null], * ]} */ -const _optionPropertyLambda = () => (['or', +export const _optionPropertyLambda = () => (['or', null, /** @type {const} */ (['|()', exp, optionLambda]), /** @type {const} */ (['|.', index, optionPropertyLambda]), @@ -350,10 +297,6 @@ const _optionPropertyLambda = () => (['or', /** @type {Phantom} */ export const optionPropertyLambda = _optionPropertyLambda -/** - * @typedef {Assert>} _OptionPropertyLambda - */ - /** * The continuation of a `dot` — a receiver is live and no region is open. * @@ -377,10 +320,6 @@ export const propertyLambda = or( /** @type {const} */ (['|?.()', exp, optionLambda]), ) -/** - * @typedef {Assert>} _PropertyLambda - */ - // Call /** @@ -399,8 +338,6 @@ export const propertyLambda = or( */ export const call = /** @type {const} */ (['()', exp, exp]) -/** @typedef {Assert>} _Call */ - // Dot /** @@ -421,8 +358,6 @@ export const call = /** @type {const} */ (['()', exp, exp]) */ export const dot = /** @type {const} */ (['.', exp, index, propertyLambda]) -/** @typedef {Assert>} _Dot */ - // Option Dot /** @@ -447,8 +382,6 @@ export const dot = /** @type {const} */ (['.', exp, index, propertyLambda]) */ export const optionDot = /** @type {const} */ (['?.', exp, index, optionPropertyLambda]) -/** @typedef {Assert>} _OptionDot */ - // Option Call /** @@ -464,8 +397,6 @@ export const optionDot = /** @type {const} */ (['?.', exp, index, optionProperty */ export const optionCall = /** @type {const} */ (['?.()', exp, exp, optionLambda]) -/** @typedef {Assert>} _OptionCall */ - // Comma /** @@ -484,10 +415,6 @@ export const optionCall = /** @type {const} */ (['?.()', exp, exp, optionLambda] */ export const comma = /** @type {const} */ ([',', exps]) -/** - * @typedef {Assert>} _Comma - */ - // No-Args Operations /** @@ -500,12 +427,8 @@ export const comma = /** @type {const} */ ([',', exps]) */ export const op0Id = or('undefined', 'args', 'frame') -/** @typedef {Assert>} _Op0Id */ - export const op0 = /** @type {const} */ ([op0Id]) -/** @typedef {Assert>} _Op0 */ - // Unary Operations /** @@ -514,12 +437,8 @@ export const op0 = /** @type {const} */ ([op0Id]) */ export const op1Id = or('String', 'Number', 'neg', '!', '~') -/** @typedef {Assert>} _Op1Id */ - export const op1 = /** @type {const} */ ([op1Id, exp]) -/** @typedef {Assert>} _Op1 */ - // Binary Operations /** @@ -552,8 +471,4 @@ export const op2Id = or( '&&', '||', '??' ) -/** @typedef {Assert>} _Op2Id */ - export const op2 = /** @type {const} */ ([op2Id, exp, exp]) - -/** @typedef {Assert>} _Op2 */ diff --git a/fjs/edag/proof.f.mjs b/fjs/edag/proof.f.mjs index 90a92faa1..39294126e 100644 --- a/fjs/edag/proof.f.mjs +++ b/fjs/edag/proof.f.mjs @@ -10,16 +10,46 @@ * which `comma` is now the sole route to; it pins the operand array's * element schema, and claims nothing about what a `,` means. * + * @import { Assert } from '../asserts/types.ts' * @import { ValidationError } from '../rtti/common/types.ts' - * @import { Unknown } from '../rtti/ts/types.ts' + * @import { Check, Check3, Unknown } from '../rtti/ts/types.ts' * @import { StringMap } from '../types/object/types.ts' + * @import { + * Array, + * Call, + * Comma, + * Dot, + * Exp, + * Exps, + * Items, + * NumberCast, + * Object, + * Op0, + * Op0Id, + * Op1, + * Op1Id, + * Op2, + * Op2Id, + * OptionCall, + * OptionDot, + * OptionLambda, + * OptionPropertyLambda, + * Primitive, + * Properties, + * Property, + * PropertyLambda, + * Spread, + * } from './types.ts' */ import { validate } from '../rtti/validate/module.f.mjs' import { assert, assertEq, assertStructurallySame, todo } from '../asserts/module.f.mjs' import { - exp, op0Id, op1Id, op2Id, - optionLambda, optionPropertyLambda, propertyLambda, + _exp, _optionLambda, _optionPropertyLambda, + array, call, comma, dot, exp, exps, items, numberCast, object, + op0, op0Id, op1, op1Id, op2, op2Id, + optionCall, optionDot, optionLambda, optionPropertyLambda, + primitive, properties, property, propertyLambda, spread, } from './module.f.mjs' /** @type {(r: readonly [string, unknown]) => void} */ @@ -99,6 +129,37 @@ const op2Ids = /** @type {const} */ ([ const desugarOptionalAt = o => o !== null && o !== undefined ? o.at : undefined export const proof = { + /** + * Each RTTI constant in `./module.f.mjs` matches its declared type in + * `./types.ts`. These are compile-time checks; the function body only has + * to exist so the typedefs have a local scope. + */ + consistency: () => { + /** @typedef {Assert>} _ExpAssert */ + /** @typedef {Assert>} _Primitive */ + /** @typedef {Assert>} _Exps */ + /** @typedef {Assert>} _Spread */ + /** @typedef {Assert>} _Items */ + /** @typedef {Assert>} _Array */ + /** @typedef {Assert>} _Property */ + /** @typedef {Assert>} _Properties */ + /** @typedef {Assert>} _Object */ + /** @typedef {Assert>} _NumberCast */ + /** @typedef {Assert>} _OptionLambda */ + /** @typedef {Assert>} _OptionPropertyLambda */ + /** @typedef {Assert>} _PropertyLambda */ + /** @typedef {Assert>} _Call */ + /** @typedef {Assert>} _Dot */ + /** @typedef {Assert>} _OptionDot */ + /** @typedef {Assert>} _OptionCall */ + /** @typedef {Assert>} _Comma */ + /** @typedef {Assert>} _Op0Id */ + /** @typedef {Assert>} _Op0 */ + /** @typedef {Assert>} _Op1Id */ + /** @typedef {Assert>} _Op1 */ + /** @typedef {Assert>} _Op2Id */ + /** @typedef {Assert>} _Op2 */ + }, primitive: { ok: () => { assertOk(v(null)) diff --git a/fjs/effects/memory/proof.f.mjs b/fjs/effects/memory/proof.f.mjs index 05a9aec19..9038e2dd0 100644 --- a/fjs/effects/memory/proof.f.mjs +++ b/fjs/effects/memory/proof.f.mjs @@ -13,16 +13,14 @@ import { } from './module.f.mjs' /** - * @typedef {{ + * @type {{ * readonly next: number, * readonly values: { readonly [key: string]: unknown }, - * }} _MemoryState + * }} */ - -/** @type {_MemoryState} */ const initial = { next: 0, values: {} } -/** @type {MemOperationMap} */ +/** @type {MemOperationMap} */ const mock = { memCreate: value => state => { const id = `k${state.next}` diff --git a/fjs/effects/memory/todo/sync-interpreter-owner.md b/fjs/effects/memory/todo/sync-interpreter-owner.md index 38a70c8d9..726bf1294 100644 --- a/fjs/effects/memory/todo/sync-interpreter-owner.md +++ b/fjs/effects/memory/todo/sync-interpreter-owner.md @@ -35,12 +35,20 @@ now exist in two variants for no reason. ### Proposal -`fjs/effects/memory` exports the sync interpreter next to the constructors: +`fjs/effects/memory` exports the sync interpreter next to the constructors, +with `MemoryState` in the module's `types.ts` (authored `.mjs` carries no +file-scope `@typedef`): + +```ts +// types.ts +export type MemoryState = { + readonly next: number + readonly values: { readonly [k: string]: unknown } +} +``` ```js -/** @typedef {{ readonly next: number, - * readonly values: { readonly [k: string]: unknown } }} MemoryState */ - +// module.f.mjs — `@import { MemoryState } from './types.ts'` in the header /** @type {MemoryState} */ export const memoryInitial = { next: 0, values: {} } diff --git a/fjs/effects/node/memory/module.mjs b/fjs/effects/node/memory/module.mjs index 6aa3ab29e..b4445dd10 100644 --- a/fjs/effects/node/memory/module.mjs +++ b/fjs/effects/node/memory/module.mjs @@ -3,9 +3,8 @@ * * @module * - * @import { Effect, ToAsyncOperationMap } from '../../types.ts' - * @import { Result } from '../../../types/result/types.ts' - * @import { Key, MemOp } from '../../memory/types.ts' + * @import { Key } from '../../memory/types.ts' + * @import { MemoryOperationMap, MemoryRun, Uuid } from './types.ts' */ import { randomUUID } from 'node:crypto' @@ -13,10 +12,6 @@ import { asyncRun } from '../../module.mjs' import { ok } from '../../../types/result/module.f.mjs' import { asBase, asNominal } from '../../memory/module.f.mjs' -/** @typedef {ToAsyncOperationMap} MemoryOperationMap */ - -/** @typedef {() => string} Uuid */ - /** @type {(id: string) => Error} */ const missingKey = id => new Error(`memory key not found: ${id}`) @@ -55,11 +50,6 @@ export const memoryOperationMap = (uuid = randomUUID) => { } } -/** - * An {@link asyncRun} runner over {@link MemOp}: an effect in, its `Result` out. - * @typedef {(effect: Effect) => Promise>} MemoryRun - */ - /** * Creates a runner owning a fresh memory store. Every effect passed to the * *same* runner shares that store; a new runner starts empty. diff --git a/fjs/effects/node/memory/proof.mjs b/fjs/effects/node/memory/proof.mjs index 6453027d6..2a8e2d413 100644 --- a/fjs/effects/node/memory/proof.mjs +++ b/fjs/effects/node/memory/proof.mjs @@ -4,7 +4,7 @@ * @module * * @import { Key } from '../../memory/types.ts' - * @import { Uuid } from './module.mjs' + * @import { Uuid } from './types.ts' */ import { errorSummary } from '../module.f.mjs' diff --git a/fjs/effects/node/memory/types.ts b/fjs/effects/node/memory/types.ts new file mode 100644 index 000000000..2b41d673d --- /dev/null +++ b/fjs/effects/node/memory/types.ts @@ -0,0 +1,19 @@ +/** + * Types for the Node.js memory-effect interpreter. + * + * @module + */ + +import type { Effect, ToAsyncOperationMap } from '../../types.ts' +import type { Result } from '../../../types/result/types.ts' +import type { MemOp } from '../../memory/types.ts' + +export type MemoryOperationMap = ToAsyncOperationMap + +export type Uuid = () => string + +/** + * An `asyncRun` (`../../module.mjs`) runner over {@link MemOp}: an effect in, + * its `Result` out. + */ +export type MemoryRun = (effect: Effect) => Promise> diff --git a/fjs/effects/node/module.mjs b/fjs/effects/node/module.mjs index b523f9b3f..37fd57a7f 100644 --- a/fjs/effects/node/module.mjs +++ b/fjs/effects/node/module.mjs @@ -13,9 +13,9 @@ * @module * * @import { Effect } from '../types.ts' - * @import { IoResult, Server as EffectServer, Headers, Module, NodeOp, RequestListener as Erl, NodeProgram, NodeProgramOptions, WriteConsoles, TestContext, TestFn, } from './types.ts' + * @import { IoResult, Server as EffectServer, Module, NodeOp, RequestListener as Erl, NodeProgram, NodeProgramOptions, WriteConsoles, TestContext, TestFn, } from './types.ts' + * @import { _Readable, _RequestListener, _Server, _ServerResponse } from './private.ts' * @import { Result } from '../../types/result/types.ts' - * @import { StringMap } from '../../types/object/types.ts' * @import { Nullable } from '../../types/nullable/types.ts' */ @@ -40,40 +40,6 @@ import { asyncTryCatch, tryCatch } from '../../types/result/module.mjs' import { fromVec, listToVec, toVec } from '../../types/uint8array/module.f.mjs' import { maxLengthBytes } from '../../types/bit_vec/module.f.mjs' -/** The one thing this runner does with the socket a `connect` event hands it. - * - * @typedef {{ readonly end: (data: string) => void }} _Socket - */ - -/** - * @typedef {{ - * readonly listen: (port: number, host: string) => void, - * readonly once: (event: string, f: (e: unknown) => void) => void, - * on(event: string, f: (req: unknown, socket: _Socket) => void): void, - * readonly removeListener: (event: string, f: (e: unknown) => void) => void, - * }} _Server - */ - -/** @typedef {AsyncIterable} _Readable */ - -/** - * @typedef {_Readable & { - * readonly method: string, - * readonly url: string, - * readonly headers: Headers, - * }} _IncomingMessage - */ - -/** - * @typedef {{ - * readonly writeHead: (status: number, headers: StringMap) => _ServerResponse, - * readonly end: (body: Uint8Array) => void, - * readonly headersSent: boolean, - * }} _ServerResponse - */ - -/** @typedef {(req: _IncomingMessage, res: _ServerResponse) => Promise} _RequestListener */ - /** * Narrowed structural view of `node:http`'s `createServer`. The official types * declare `method`/`url` optional and header values as @@ -83,8 +49,6 @@ import { maxLengthBytes } from '../../types/bit_vec/module.f.mjs' */ const createServer = http.createServer -/** @typedef {(effect: Effect) => Promise>} _EffectToPromise */ - /** * Performs host IO, reporting a thrown failure as an {@link IoResult} error. * @@ -355,7 +319,7 @@ const randomMax = Number(1n << 32n) const { randomInt } = crypto -/** @type {_EffectToPromise} */ +/** @type {(effect: Effect) => Promise>} */ const runNodeEffect = asyncRun({ ...memoryOperationMap(), all: async (...effects) => ok(await Promise.all(effects.map(runNodeEffect))), @@ -553,9 +517,7 @@ const inlineTest = async (name, { expectFailure }, fn) => { /** @type {TestContext} */ const inlineContext = { test: inlineTest } -/** @typedef {(name: string, fn: () => Promise) => Promise} _FrameworkRegister */ - -/** @type {(register: _FrameworkRegister) => TestContext} */ +/** @type {(register: (name: string, fn: () => Promise) => Promise) => TestContext} */ const wrapInlineTest = register => ({ test: (name, opts, fn) => register(name, () => inlineTest(name, opts, fn)) }) diff --git a/fjs/effects/node/private.ts b/fjs/effects/node/private.ts new file mode 100644 index 000000000..d3d942a74 --- /dev/null +++ b/fjs/effects/node/private.ts @@ -0,0 +1,38 @@ +/** + * Implementation-private types for the Node.js effect runner: the narrowed + * structural views of `node:http` objects the runner interprets HTTP + * operations against. + * + * @module + */ + +import type { StringMap } from '../../types/object/types.ts' +import type { Headers } from './types.ts' + +/** The one thing the runner does with the socket a `connect` event hands it. */ +export type _Socket = { + readonly end: (data: string) => void +} + +export type _Server = { + readonly listen: (port: number, host: string) => void + readonly once: (event: string, f: (e: unknown) => void) => void + on(event: string, f: (req: unknown, socket: _Socket) => void): void + readonly removeListener: (event: string, f: (e: unknown) => void) => void +} + +export type _Readable = AsyncIterable + +export type _IncomingMessage = _Readable & { + readonly method: string + readonly url: string + readonly headers: Headers +} + +export type _ServerResponse = { + readonly writeHead: (status: number, headers: StringMap) => _ServerResponse + readonly end: (body: Uint8Array) => void + readonly headersSent: boolean +} + +export type _RequestListener = (req: _IncomingMessage, res: _ServerResponse) => Promise diff --git a/fjs/effects/proof.f.mjs b/fjs/effects/proof.f.mjs index 102ad602c..0f095b347 100644 --- a/fjs/effects/proof.f.mjs +++ b/fjs/effects/proof.f.mjs @@ -1,6 +1,8 @@ /** - * @import { Effect, Func, Operation } from './types.ts' + * @import { Assert } from '../asserts/types.ts' + * @import { Effect, Func, NotImplemented, Operation } from './types.ts' * @import { Result } from '../types/result/types.ts' + * @import { Equal } from '../types/ts/types.ts' */ import { @@ -35,10 +37,9 @@ const assertPure = (e, expected) => { * `Operation` requires a `Result` return, so a runner always has somewhere to * answer `error(notImplemented)` — and that requirement is what lets an effect * carry its error channel in the type rather than inside an opaque payload. - * @typedef {readonly['add', (a: number, b: number) => Result]} _AddOp + * @type {(command: 'add') => (a: number, b: number) => + * Effect Result], number, string>} */ - -/** @type {(command: 'add') => (a: number, b: number) => Effect<_AddOp, number, string>} */ const doAdd = do_ const next = match({ @@ -51,10 +52,9 @@ const next = match({ * nothing stops it naming a member `map` inherits from `Object.prototype` * rather than an own handler. `match` must refuse those, and this type is how a * proof says so without an `as` cast. - * @typedef {readonly[string, (a: number) => Result]} _AnyOp + * @type {(command: string) => (a: number) => + * Effect Result], number, string>} */ - -/** @type {(command: string) => (a: number) => Effect<_AnyOp, number, string>} */ const doAny = do_ const anyNext = match({ add: (/** @type {number} */ a) => ok(a + 1) }) @@ -78,21 +78,15 @@ const anyPartial = partialMatch( * A fallible operation, spelled the way every operation is spelled: the * `Result` is in the command's declared return type, so `do_` already builds an * `Effect` and the runner's handler already answers with `ok` / `error`. - * @typedef {readonly['div', (a: number, b: number) => Result]} _DivOp + * @type {Func Result]>} */ +const div = do_('div') /** * A second operation, so a chain can join two of them and the operation sets * union. - * @typedef {readonly['neg', (a: number) => Result]} _NegOp + * @type {Func Result]>} */ - -/** @typedef {_DivOp | _NegOp} _Op */ - -/** @type {Func<_DivOp>} */ -const div = do_('div') - -/** @type {Func<_NegOp>} */ const neg = do_('neg') const nextArith = match({ @@ -104,7 +98,10 @@ const nextArith = match({ /** * Runs an effect to completion against the two operations above — `asyncRun`'s * loop without the `await`, which is all a synchronous runner is. - * @type {(e: Effect<_Op, T, E>) => Result} + * @type {(e: Effect< + * | readonly['div', (a: number, b: number) => Result] + * | readonly['neg', (a: number) => Result], + * T, E>) => Result} */ const run = e => { let current = e @@ -176,6 +173,86 @@ const checked = v => { const show = e => `${e}` export const proof = { + /** + * Every combinator's signature, pinned at a concrete instantiation. These + * verify `./module.f.mjs`, so they live here rather than in `./types.ts`; + * the widening rules the layer itself rests on stay there. + */ + signatures: () => { + /** @typedef {readonly['add', (a: number, b: number) => Result]} _AddOp */ + /** @typedef {readonly['mul', (a: number, b: number) => Result]} _MulOp */ + // `step` unions the operation sets and the errors, and replaces the + // success type with the continuation's. + /** + * @typedef {Assert>, + * Effect<_AddOp | _MulOp, string, NotImplemented | string>>>} _StepSig + */ + // `catchStep` mirrors it: the success channel is the union of the + // preserved value and the recovery's, and the error type is the + // recovery's alone — `never` when every error is handled. + /** + * @typedef {Assert>, + * Effect<_AddOp | _MulOp, string | number, never>>>} _CatchStepSig + */ + // `resultStep` consumes both branches, so it replaces both channels and + // unions only the operation sets. It is the layer's primitive — `step` + // and `catchStep` are it with a tag test in front — so this signature + // is the one the other two are derived from rather than a third + // variant beside them. + /** + * @typedef {Assert>, + * Effect<_AddOp | _MulOp, string, string>>>} _ResultStepSig + */ + // `mapStep` widens nothing: a pure projection issues no commands and + // cannot fail, so only the success type changes. + /** + * @typedef {Assert>, + * Effect<_AddOp, string, NotImplemented>>>} _MapStepSig + */ + // `resultMapStep` is the both-branches projection, so it replaces the + // error channel as well — this is the assert that says a caller may + // discard errors here, which is the whole reason the name is separate + // from `mapStep`'s. + /** + * @typedef {Assert>>, + * Effect<_AddOp, string, string>>>} _ResultMapStepSig + */ + // ...and a projection that only ever answers `ok` empties the channel + // rather than acquiring one. Reading the two halves off `f`'s concrete + // return type is what makes this line pass; matching `Result` + // directly would infer `F` from the `ok` payload. + /** + * @typedef {Assert>, + * Effect<_AddOp, string, never>>>} _ResultMapStepEmpties + */ + // `unwrapStep` panics on the error branch, so what it hands back is an + // effect whose channel is empty — `never` earned by the throw rather + // than asserted. + /** + * @typedef {Assert>, + * Effect<_AddOp, number, never>>>} _UnwrapStepSig + */ + // ...and the renderer it takes is what stops that panic from quietly + // growing. A summary written for one channel is *not* usable where a + // wider channel's summary is required — parameters are contravariant — + // so adding a failure upstream breaks the site that chose to panic + // instead of silently enlarging what it crashes on. This is the assert + // that makes the argument checkable: were it to pass, `unwrapStep` + // would be back to absorbing anything. + /** @template E @typedef {Parameters>[1]} _Summary */ + /** + * @typedef {Assert extends _Summary ? true : false, + * false>>} _UnwrapStepPinsItsChannel + */ + }, runPure: { ok: () => { assertPure(pure(ok(5)), ok(5)) @@ -321,6 +398,7 @@ export const proof = { overFailedDo: () => { // `todo` never returns, so it pins none of the continuation's type // parameters; the annotation supplies the operation set `run` needs. + /** @typedef {readonly['div', (a: number, b: number) => Result]} _DivOp */ /** @type {Effect<_DivOp, never, string>} */ const e = step(div(1, 0), todo) assertError(run(e), 'div by zero') @@ -328,7 +406,9 @@ export const proof = { // Adjacent links performing different commands: the operation sets // union, so one runner interprets the whole chain. joinsOperations: () => { - /** @type {Effect<_Op, number, string>} */ + /** @typedef {readonly['div', (a: number, b: number) => Result]} _DivOp */ + /** @typedef {readonly['neg', (a: number) => Result]} _NegOp */ + /** @type {Effect<_DivOp | _NegOp, number, string>} */ const e = step(div(6, 3), neg) assertOk(run(e), -2) }, diff --git a/fjs/effects/types.ts b/fjs/effects/types.ts index 4164e2db9..8ea1f3a5b 100644 --- a/fjs/effects/types.ts +++ b/fjs/effects/types.ts @@ -8,9 +8,6 @@ import type { Ok, Error, Result } from '../types/result/types.ts' import type { Assert } from '../asserts/types.ts' import type { Unknown as Json } from '../media/json/types.ts' import type { Equal } from '../types/ts/types.ts' -import type { - catchStep, mapStep, resultMapStep, resultStep, step, unwrapStep, -} from './module.f.mjs' /** * A command name paired with the signature a runner implements it at. @@ -280,11 +277,13 @@ export type Func = // ── The contract, checked rather than merely declared ──────────────────────── // -// Every combinator's signature is pinned below at a concrete instantiation, -// together with the widening rules the layer rests on. The union rules are the -// subtle part: a "simplification" that unified an error channel instead of -// unioning it would still compile at the definition and fail only at some -// future call site, so each rule is written down as a check here. +// The widening rules the layer rests on are pinned below at a concrete +// instantiation. The union rules are the subtle part: a "simplification" that +// unified an error channel instead of unioning it would still compile at the +// definition and fail only at some future call site, so each rule is written +// down as a check here. The combinator signatures themselves verify +// `./module.f.mjs`, so those asserts live downstream in `./proof.f.mjs`'s +// `signatures` proof rather than here. /** @see {@link _WidensOperations} — a second command to widen the op-set with. */ type _AddOp = readonly['add', (a: number, b: number) => Result] @@ -322,66 +321,6 @@ type _WidensOk = Assert<_Add extends Effect<_AddOp, number | string, NotImplemen // composing with one that requests further commands. type _WidensOperations = Assert<_Add extends Effect<_AddOp | _MulOp, number, NotImplemented> ? true : false> -// `step` unions the operation sets and the errors, and replaces the success -// type with the continuation's. -type _StepSig = Assert>, - Effect<_AddOp | _MulOp, string, NotImplemented | string>>> - -// `catchStep` mirrors it: the success channel is the union of the preserved -// value and the recovery's, and the error type is the recovery's alone — -// `never` when every error is handled. -type _CatchStepSig = Assert>, - Effect<_AddOp | _MulOp, string | number, never>>> - -// `resultStep` consumes both branches, so it replaces both channels and unions -// only the operation sets. It is the layer's primitive — `step` and `catchStep` -// are it with a tag test in front — so this signature is the one the other two -// are derived from rather than a third variant beside them. -type _ResultStepSig = Assert>, - Effect<_AddOp | _MulOp, string, string>>> - -// `mapStep` widens nothing: a pure projection issues no commands and cannot -// fail, so only the success type changes. -type _MapStepSig = Assert>, - Effect<_AddOp, string, NotImplemented>>> - -// `resultMapStep` is the both-branches projection, so it replaces the error -// channel as well — this is the assert that says a caller may discard errors -// here, which is the whole reason the name is separate from `mapStep`'s. -type _ResultMapStepSig = Assert>>, - Effect<_AddOp, string, string>>> - -// ...and a projection that only ever answers `ok` empties the channel rather -// than acquiring one. Reading the two halves off `f`'s concrete return type is -// what makes this line pass; matching `Result` directly would infer `F` -// from the `ok` payload. -type _ResultMapStepEmpties = Assert>, - Effect<_AddOp, string, never>>> - -// `unwrapStep` panics on the error branch, so what it hands back is an effect -// whose channel is empty — `never` earned by the throw rather than asserted. -type _UnwrapStepSig = Assert>, - Effect<_AddOp, number, never>>> - -// ...and the renderer it takes is what stops that panic from quietly growing. -// A summary written for one channel is *not* usable where a wider channel's -// summary is required — parameters are contravariant — so adding a failure -// upstream breaks the site that chose to panic instead of silently enlarging -// what it crashes on. This is the assert that makes the argument checkable: -// were it to pass, `unwrapStep` would be back to absorbing anything. -type _Summary = Parameters>[1] - -type _UnwrapStepPinsItsChannel = Assert extends _Summary ? true : false, - false>> - // `NotImplemented` is JSON data. This is the assert the "command name only" // rule exists to keep true: an operation's payload may hold functions, and // admitting one here would fail this line. The dependency is type-only and diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 192d3318f..a94cb622a 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -15,7 +15,7 @@ * * @module * - * @import { TestResult, _TestAndPath } from './types.ts' + * @import { BrowserTestReport, TestResult, _BrowserImporter, _BrowserTestResult, _TestAndPath } from './types.ts' * @import { Result } from '../types/result/types.ts' */ @@ -59,21 +59,6 @@ const errorDetails = error => { return [fallback, fallback] } -/** - * A leaf's outcome as the page reports it: the shared {@link TestResult} — - * identity, status and duration, decided by `testResult` rather than here — plus - * the two fields only a browser report needs. - * - * `message` and `stack` are the browser's own part, and stay outside the shared - * record for the reason `TestResult` gives: describing a thrown value needs the - * value, a serializable report cannot carry one, and `fjs t` describes it - * differently because it is writing to a terminal rather than to a wire. - * - * @typedef {TestResult & { readonly message?: string, readonly stack?: string }} _BrowserTestResult - */ - -/** @typedef {{ readonly status: string, readonly browser: string, readonly totals: { readonly tests: number, readonly passed: number, readonly failed: number }, readonly duration: number, readonly results: readonly _BrowserTestResult[] }} BrowserTestReport */ - /** * A failure of a whole module — one that will not link, or whose `proof` export * cannot be enumerated. It does not go through `testResult`, and that is the @@ -266,11 +251,7 @@ export const runBrowserProofs = (modules, result = () => undefined) => { )) } -/** @typedef {(source: string) => Promise<{ readonly proof?: unknown }>} _BrowserImporter */ -/** @typedef {{ readonly status: 'loaded', readonly source: string, readonly proof: unknown } | { readonly status: 'error', readonly source: string, readonly error: unknown }} _LoadedModule */ -/** @typedef {Window & { fjsBrowserTestReport?: Promise }} _TestWindow */ - -/** @type {(root: Element) => _TestWindow | null} */ +/** @type {(root: Element) => (Window & { fjsBrowserTestReport?: Promise }) | null} */ const viewOf = root => root.ownerDocument.defaultView /** @@ -298,6 +279,7 @@ const publish = (root, report) => { * @type {(root: Element, sources: readonly string[], importer: _BrowserImporter) => Promise} */ export const startBrowserTestSources = (root, sources, importer) => { + /** @typedef {{ readonly status: 'loaded', readonly source: string, readonly proof: unknown } | { readonly status: 'error', readonly source: string, readonly error: unknown }} _LoadedModule */ const start = performance.now() setState(root, 'loading') let loaded = 0 diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 887f9d150..6b542ec61 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -14,90 +14,101 @@ import { renderBrowserReport, runBrowserProofs, startBrowserTests, startBrowserT import { fmtImport, testResult } from '../module.f.mjs' import { error, ok } from '../../types/result/module.f.mjs' -/** @typedef {{ readonly tag: string, attributes: ReadonlyMap, readonly ownerDocument: _Document, textContent: string, children: readonly _Element[], readonly setAttribute: (name: string, value: string) => void, readonly removeAttribute: (name: string) => void, readonly querySelector: (selector: string) => _Element | null, readonly replaceChildren: (...nodes: readonly _Element[]) => void, readonly append: (node: _Element) => void }} _Element */ -/** @typedef {{ defaultView: _View | null, readonly createElement: (tag: string) => _Element }} _Document */ -/** @typedef {{ events: readonly CustomEvent[], readonly dispatchEvent: (event: Event) => boolean, fjsBrowserTestReport?: Promise }} _View */ +/** + * Builds the DOM stand-in the proofs drive the runner with. A single factory + * rather than file-scope helpers so the mutually recursive + * element/document/view types can stay function-local. + */ +const dom = () => { + /** @typedef {{ readonly tag: string, attributes: ReadonlyMap, readonly ownerDocument: _Document, textContent: string, children: readonly _Element[], readonly setAttribute: (name: string, value: string) => void, readonly removeAttribute: (name: string) => void, readonly querySelector: (selector: string) => _Element | null, readonly replaceChildren: (...nodes: readonly _Element[]) => void, readonly append: (node: _Element) => void }} _Element */ + /** @typedef {{ defaultView: _View | null, readonly createElement: (tag: string) => _Element }} _Document */ + /** @typedef {{ events: readonly CustomEvent[], readonly dispatchEvent: (event: Event) => boolean, fjsBrowserTestReport?: Promise }} _View */ -/** @type {(node: _Element, name: string) => _Element | null} */ -const find = (node, name) => - node.attributes.has(name) - ? node - : node.children.reduce( - (/** @type {_Element | null} */ acc, child) => acc ?? find(child, name), - null) + /** @type {(node: _Element, name: string) => _Element | null} */ + const find = (node, name) => + node.attributes.has(name) + ? node + : node.children.reduce( + (/** @type {_Element | null} */ acc, child) => acc ?? find(child, name), + null) -/** @type {(document: _Document, tag: string, attributes: readonly string[], states: string[]) => _Element} */ -const element = (document, tag, attributes, states) => { - /** @type {_Element} */ - const self = { - tag, - attributes: new Map(attributes.map(name => [name, ''])), - ownerDocument: document, - textContent: '', - children: [], - setAttribute: (name, value) => { - if (name === 'data-state') { states.push(value) } - self.attributes = new Map([...self.attributes, [name, value]]) - }, - removeAttribute: name => { - self.attributes = new Map([...self.attributes].filter(([key]) => key !== name)) - }, - // The runner only ever queries an attribute selector of `[name]` form. - querySelector: selector => self.children.reduce( - (/** @type {_Element | null} */ acc, child) => - acc ?? find(child, selector.slice(1, -1)), - null), - replaceChildren: (...nodes) => { self.children = nodes }, - append: node => { self.children = [...self.children, node] }, + /** @type {(document: _Document, tag: string, attributes: readonly string[], states: string[]) => _Element} */ + const element = (document, tag, attributes, states) => { + /** @type {_Element} */ + const self = { + tag, + attributes: new Map(attributes.map(name => [name, ''])), + ownerDocument: document, + textContent: '', + children: [], + setAttribute: (name, value) => { + if (name === 'data-state') { states.push(value) } + self.attributes = new Map([...self.attributes, [name, value]]) + }, + removeAttribute: name => { + self.attributes = new Map([...self.attributes].filter(([key]) => key !== name)) + }, + // The runner only ever queries an attribute selector of `[name]` form. + querySelector: selector => self.children.reduce( + (/** @type {_Element | null} */ acc, child) => + acc ?? find(child, selector.slice(1, -1)), + null), + replaceChildren: (...nodes) => { self.children = nodes }, + append: node => { self.children = [...self.children, node] }, + } + return self } - return self -} -/** - * Builds what the generated page gives the runner: a root carrying the summary - * paragraph and the result list. `states` records every `data-state` written, - * so a proof can check the whole progression and not just its last step. - * - * @type {(withView?: boolean) => { readonly root: Element, readonly summary: _Element, readonly results: _Element, readonly runButton: _Element, readonly view: _View, readonly states: readonly string[] }} - */ -const page = (withView = true) => { - /** @type {string[]} */ - const states = [] - /** @type {_Document} */ - const document = { - defaultView: null, - createElement: tag => element(document, tag, [], states), - } - /** @type {_View} */ - const view = { - events: [], - dispatchEvent: event => { - view.events = [...view.events, /** @type {CustomEvent} */ (event)] - return true - }, - } - if (withView) { document.defaultView = view } - const root = element(document, 'main', ['data-browser-tests'], states) - root.replaceChildren( - element(document, 'p', ['data-test-summary'], states), - element(document, 'button', ['data-test-run'], states), - element(document, 'ol', ['data-test-results'], states)) - return { - root: /** @type {Element} */ (/** @type {unknown} */ (root)), - summary: assertNotNullish(root.querySelector('[data-test-summary]')), - results: assertNotNullish(root.querySelector('[data-test-results]')), - runButton: assertNotNullish(root.querySelector('[data-test-run]')), - view, - states, + /** + * Builds what the generated page gives the runner: a root carrying the summary + * paragraph and the result list. `states` records every `data-state` written, + * so a proof can check the whole progression and not just its last step. + * + * @type {(withView?: boolean) => { readonly root: Element, readonly summary: _Element, readonly results: _Element, readonly runButton: _Element, readonly view: _View, readonly states: readonly string[] }} + */ + const page = (withView = true) => { + /** @type {string[]} */ + const states = [] + /** @type {_Document} */ + const document = { + defaultView: null, + createElement: tag => element(document, tag, [], states), + } + /** @type {_View} */ + const view = { + events: [], + dispatchEvent: event => { + view.events = [...view.events, /** @type {CustomEvent} */ (event)] + return true + }, + } + if (withView) { document.defaultView = view } + const root = element(document, 'main', ['data-browser-tests'], states) + root.replaceChildren( + element(document, 'p', ['data-test-summary'], states), + element(document, 'button', ['data-test-run'], states), + element(document, 'ol', ['data-test-results'], states)) + return { + root: /** @type {Element} */ (/** @type {unknown} */ (root)), + summary: assertNotNullish(root.querySelector('[data-test-summary]')), + results: assertNotNullish(root.querySelector('[data-test-results]')), + runButton: assertNotNullish(root.querySelector('[data-test-run]')), + view, + states, + } } + + /** @type {(element: _Element) => readonly (string | undefined)[]} */ + const statuses = element => element.children.map(child => child.attributes.get('data-status')) + + return { element, page, statuses } } +const { element, page, statuses } = dom() + /** @type {(proof: unknown) => ReturnType} */ const run = proof => runBrowserProofs([['proof', proof]]) -/** @type {(element: _Element) => readonly (string | undefined)[]} */ -const statuses = element => element.children.map(child => child.attributes.get('data-status')) - export const proof = { namedThrow: async () => { const named = { throw: () => { throw 'expected' } }.throw @@ -466,7 +477,7 @@ export const proof = { // than throwing. /** @type {string[]} */ const states = [] - /** @type {_Document} */ + /** @type {Parameters[0]} */ const document = { defaultView: null, createElement: tag => element(document, tag, [], states), diff --git a/fjs/emergent_testing/proof.f.mjs b/fjs/emergent_testing/proof.f.mjs index a6abb8539..8bd7161eb 100644 --- a/fjs/emergent_testing/proof.f.mjs +++ b/fjs/emergent_testing/proof.f.mjs @@ -39,21 +39,17 @@ const event = or( /** @type {const} */ (['summary', rttiNumber, rttiNumber, rttiNumber]), ) -/** @typedef {Ts} _Event */ - const parseEvent = rttiParse(event) -/** @typedef {Reporter} _TestReporter */ - -/** @type {(e: _Event) => Effect} */ +/** @type {(e: Ts) => Effect} */ const writeEvent = e => log(JSON.stringify(e)) -/** @type {(stdout: string) => readonly _Event[]} */ +/** @type {(stdout: string) => readonly Ts[]} */ const parseEvents = stdout => stdout === '' ? [] : stdout.trimEnd().split('\n') .map(line => unwrap(parseEvent(unwrap(parseJson(line))))) -/** @type {() => _TestReporter} */ +/** @type {() => Reporter} */ const makeReporter = () => ({ result: (file, path, _r, _throws) => writeEvent(['result', file, [...path]]), summary: (pass, fail, time) => writeEvent(['summary', pass, fail, time]), @@ -73,7 +69,7 @@ const fail0 = () => ({ result: /** @type {const} */ (['error', 'oops']), duratio /** @type {() => unknown} */ const ok1 = () => ({ result: /** @type {const} */ (['ok', undefined]), duration: 1 }) -/** @type {(dir: Record, initCwd?: string) => readonly [readonly _Event[], number]} */ +/** @type {(dir: Record, initCwd?: string) => readonly [readonly Ts[], number]} */ const run = (dir, initCwd = '.') => { const reporter = makeReporter() const state = { ...emptyState, root: dir } @@ -301,8 +297,6 @@ export const githubReporterOutput = () => { ) } -/** @typedef {All | Import | Readdir | Sandbox | Write} _FailOps */ - // A reporter that cannot write neither panics nor reports success. The failed // `result` line short-circuits its own test, leaves `allOk` as the first error, // skips the summary, and reaches the program tail — which answers exit `1`. @@ -311,6 +305,7 @@ export const githubReporterOutput = () => { // the failure on, so the exit code rather than a message is what is observable: // a run that cannot say anything at all still says it failed. export const reporterWriteFailure = () => { + /** @typedef {All | Import | Readdir | Sandbox | Write} _FailOps */ /** @type {RunInstance<_FailOps, undefined>} */ let runner runner = mockRun(/** @type {Parameters>[0]} */ ({ @@ -335,23 +330,6 @@ export const reporterWriteFailure = () => { assertEq(exitCode(code), 1) } -/** @typedef {readonly string[]} _RegisterMockState */ - -/** @typedef {Test | All | Await} _RegisterMockOps */ - -/** @typedef {RunInstance<_RegisterMockOps, _RegisterMockState>} _RegisterRunner */ - -/** - * The `test` op body for a `registerModule` mock; `runner` is threaded in explicitly (rather than closed over) so it can recurse into sub-effects returned by `fn`. - * @typedef {( - * runner: _RegisterRunner, - * ctx: TestContext, - * name: string, - * expectFailure: boolean, - * fn: (t: TestContext) => Effect<_RegisterMockOps, void, never>, - * ) => (s: _RegisterMockState) => readonly [_RegisterMockState, OpResult]} _RegisterTestOp - */ - /** * A `TestContext` that is never invoked. Every mock runner below intercepts the * `test` *effect* and reads the context as data, so `test` here exists only to @@ -369,9 +347,23 @@ const registerNoopCtx = { test: (_n, _o, _f) => { throw 'registerNoopCtx is data * Builds a synchronous mock runner for `registerModule`'s `Test`/`All`/`Await` * effect operations. Only the `test` op varies between call sites (whether it * invokes the registered callback), so `all`/`await` are shared here. + * + * `testOp` is the `test` op body for a `registerModule` mock; `runner` is + * threaded in explicitly (rather than closed over) so it can recurse into + * sub-effects returned by `fn`. */ -/** @type {(testOp: _RegisterTestOp) => _RegisterRunner} */ +/** @type {(testOp: ( + * runner: RunInstance, + * ctx: TestContext, + * name: string, + * expectFailure: boolean, + * fn: (t: TestContext) => Effect, + * ) => (s: readonly string[]) => readonly [readonly string[], OpResult] + * ) => RunInstance} */ const makeRegisterRunner = testOp => { + /** @typedef {readonly string[]} _RegisterMockState */ + /** @typedef {Test | All | Await} _RegisterMockOps */ + /** @typedef {RunInstance<_RegisterMockOps, _RegisterMockState>} _RegisterRunner */ /** @type {_RegisterRunner} */ let runner runner = mockRun(/** @type {Parameters>[0]} */ ({ @@ -420,6 +412,9 @@ export const registerSuffixes = () => { // which is why `registerOne` ends in a `catchStep` that throws rather than in // a channel nobody reads. const registerBodyPanicsOnUndispatchableEffect = () => { + /** @typedef {readonly string[]} _RegisterMockState */ + /** @typedef {Test | All | Await} _RegisterMockOps */ + /** @typedef {RunInstance<_RegisterMockOps, _RegisterMockState>} _RegisterRunner */ /** @type {_RegisterRunner} */ let runner runner = mockRun(/** @type {Parameters>[0]} */ ({ @@ -496,6 +491,7 @@ export const registerEmptyModuleMap = () => { // so a swapped `engine` ternary or a deleted `inlineTestContext` branch // changes what's observed here, not just whether the line ran. export const registerSelectsContextAndStar = () => { + /** @typedef {Test | All | Await} _RegisterMockOps */ /** @type {TestContext} */ const nodeCtx = { test: todo } /** @type {TestContext} */ diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index ed5888d7d..2a503a17c 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -96,6 +96,44 @@ export type TestResult = { readonly duration: number } +/** + * A leaf's outcome as the browser page reports it: the shared + * {@link TestResult} — identity, status and duration, decided by `testResult` + * rather than by the browser runner — plus the two fields only a browser + * report needs. + * + * `message` and `stack` are the browser's own part, and stay outside the shared + * record for the reason `TestResult` gives: describing a thrown value needs the + * value, a serializable report cannot carry one, and `fjs t` describes it + * differently because it is writing to a terminal rather than to a wire. + * + * @internal + */ +export type _BrowserTestResult = TestResult & { + readonly message?: string + readonly stack?: string +} + +/** The serializable report a browser test run resolves with. */ +export type BrowserTestReport = { + readonly status: string + readonly browser: string + readonly totals: { + readonly tests: number + readonly passed: number + readonly failed: number + } + readonly duration: number + readonly results: readonly _BrowserTestResult[] +} + +/** + * Loads one proof module by its source path for the browser runner. + * + * @internal + */ +export type _BrowserImporter = (source: string) => Promise<{ readonly proof?: unknown }> + /** * Receives semantic test-run events. Each method is the runner's notification * of an event; the reporter decides how to render it (terminal, GitHub diff --git a/fjs/fsc/README.md b/fjs/fsc/README.md index 2ea737ab0..bd4eba3be 100644 --- a/fjs/fsc/README.md +++ b/fjs/fsc/README.md @@ -126,28 +126,28 @@ move, so a `module.f.mjs` is accompanied by a `proof.f.mjs`. Type-only APIs may remain in `types.ts`. Current FunctionalScript compiler support was never a condition for that rename. -#### Private JSDoc typedefs - -TypeScript declaration emit currently turns JSDoc `@typedef`s into exported type -aliases, including typedefs that exist only as implementation details. This is -tracked upstream by -[microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407). - -Until JSDoc typedefs can be stripped with `@internal` and `stripInternal`, use a -leading `_` for implementation-only typedefs created during the migration: - -```js -/** @typedef {number} _Type */ -``` - -The underscore is an API contract, not declaration-level visibility. Generated -`.d.ts` / `.d.mts` may still contain `export type _Type = number`, but names that -begin with `_` are private FunctionalScript implementation details. Consumers -must not rely on those names directly, so renaming or removing a `_`-prefixed -alias is not a breaking change solely because TypeScript emitted it. The public -contract still governs transitive effects: if a public type depends on `_Type`, -changing `_Type` in a way that changes that public type's assignability is a -breaking change and requires the normal `**BREAKING CHANGES:**` treatment. +#### Private types + +Authored `.mjs` files carry no file-scope JSDoc `@typedef` — anywhere in the +repository (see the repository-wide rule in the root `AGENTS.md` and +`fjs/AGENTS.md` §3.2). A named type migrating out of a `.f.ts` therefore lands +in the sibling `types.ts` (when it is part of the public declaration closure), +in an optional sibling `private.ts` (implementation-private types outside that +closure), inline in the annotations that use it, or — for compile-time proof +types — function-local in a proof. + +Private types and private runtime constants keep a leading `_`, even when +linkage requires an export. The underscore is an API contract, not +declaration-level visibility: generated `.d.ts` / `.d.mts` may still contain +`export type _Type = number` (and, until the packaging stage of +[`../todo/separate-private-types.md`](../todo/separate-private-types.md) lands, +a generated `private.d.ts` still ships), but names that begin with `_` are +private FunctionalScript implementation details. Consumers must not rely on +those names directly, so renaming or removing a `_`-prefixed name is not a +breaking change solely because TypeScript emitted it. The public contract still +governs transitive effects: if a public type depends on `_Type`, changing +`_Type` in a way that changes that public type's assignability is a breaking +change and requires the normal `**BREAKING CHANGES:**` treatment. For example, suppose the generated declaration initially contains: @@ -176,18 +176,18 @@ export type Public = readonly [_Internal] The emitted private alias is still private, but the expanded public contract of `Public` changed from `readonly [number]` to `readonly [string]`. -Public JSDoc typedefs keep ordinary names without the `_` prefix. Which JSDoc -typedefs are public is an API design decision, not a mechanical restatement of -what the pre-migration `.f.ts` file happened to export: a helper that belongs to -the module's public vocabulary may be published under an ordinary name even -though its TypeScript alias was module-private, and a former export may become -`_` when it only ever described an implementation detail. Types intentionally -separated into `types.ts` use ordinary TypeScript source visibility instead of -this JSDoc-emission workaround. - -When upstream support is ready, replace this workaround with `@internal`; that -cleanup is tracked by -[`todo/blocked/jsdoc-typedef-strip-internal.md`](../../todo/blocked/jsdoc-typedef-strip-internal.md). +Public types keep ordinary names without the `_` prefix. Which types are public +is an API design decision, not a mechanical restatement of what the +pre-migration `.f.ts` file happened to export: a helper that belongs to the +module's public vocabulary may be published under an ordinary name even though +its TypeScript alias was module-private, and a former export may become `_` +when it only ever described an implementation detail. + +Removing shipped private declaration artifacts (`private.d.ts`) from the +package is the second stage of +[`../todo/separate-private-types.md`](../todo/separate-private-types.md); the +`_` contract itself is permanent, since `_` helpers in `types.ts` and exported +`_` constants keep shipping in emitted declarations regardless. When the last authored implementation/proof `.ts` / `.f.ts` file is gone, authored `types.ts` files may remain. The TypeScript runtime-emission pass is diff --git a/fjs/fsc/module.f.mjs b/fjs/fsc/module.f.mjs index 920f85777..c06067065 100644 --- a/fjs/fsc/module.f.mjs +++ b/fjs/fsc/module.f.mjs @@ -6,6 +6,7 @@ * @import { RangeMapArray, RangeMerge } from '../types/range_map/types.ts' * @import { List } from '../types/list/types.ts' * @import { Range } from '../types/range/types.ts' + * @import { _CreateToResult, _Result, _State, _ToResult } from './types.ts' */ import { strictEqual } from '../types/function/operator/module.f.mjs' @@ -18,14 +19,6 @@ import { assertEq } from '../asserts/module.f.mjs' const fromCharCode = String.fromCharCode -/** @typedef {readonly [readonly string[], _ToResult]} _Result */ - -/** @typedef {(codePoint: number) => _Result} _ToResult */ - -/** @template T @typedef {(state: T) => _ToResult} _CreateToResult */ - -/** @template T @typedef {RangeMapArray<_CreateToResult>} _State */ - /** @type {_ToResult} */ const unexpectedSymbol = codePoint => [[`unexpected symbol ${codePoint}`], unexpectedSymbol] diff --git a/fjs/fsc/types.ts b/fjs/fsc/types.ts new file mode 100644 index 000000000..3c2dccdfe --- /dev/null +++ b/fjs/fsc/types.ts @@ -0,0 +1,16 @@ +/** + * Types for the FunctionalScript compile-workflow state machine. + * + * @module + */ + +import type { RangeMapArray } from '../types/range_map/types.ts' + +/** A step outcome: diagnostics so far, and the next code-point handler. */ +export type _Result = readonly [readonly string[], _ToResult] + +export type _ToResult = (codePoint: number) => _Result + +export type _CreateToResult = (state: T) => _ToResult + +export type _State = RangeMapArray<_CreateToResult> diff --git a/fjs/fsm/module.f.mjs b/fjs/fsm/module.f.mjs index 383cdadc1..709077c2f 100644 --- a/fjs/fsm/module.f.mjs +++ b/fjs/fsm/module.f.mjs @@ -9,6 +9,7 @@ * @import { SortedSet } from '../types/sorted_set/types.ts' * @import { RangeMap, Properties, RangeMapArray, Entry } from '../types/range_map/types.ts' * @import { Fold } from '../types/function/operator/types.ts' + * @import { Grammar, _Dfa, _Rule } from './types.ts' */ import { equal, isEmpty, fold, map, toArray, foldScan, empty as emptyList } from '../types/list/module.f.mjs' @@ -21,12 +22,6 @@ import { compose } from '../types/function/module.f.mjs' import { at } from '../types/object/module.f.mjs' import { cmp } from '../types/string/module.f.mjs' -/** @typedef {readonly [string, ByteSet, string]} _Rule */ - -/** @typedef {List<_Rule>} Grammar */ - -/** @typedef {StringMap>} _Dfa */ - /** * The byte set of an inclusive ASCII character range, written as the two diff --git a/fjs/fsm/proof.f.mjs b/fjs/fsm/proof.f.mjs index 5f8e47882..7d8a2e56a 100644 --- a/fjs/fsm/proof.f.mjs +++ b/fjs/fsm/proof.f.mjs @@ -1,5 +1,5 @@ /** - * @import { Grammar } from './module.f.mjs' + * @import { Grammar } from './types.ts' * @import { ByteSet } from '../types/byte_set/types.ts' */ diff --git a/fjs/fsm/types.ts b/fjs/fsm/types.ts new file mode 100644 index 000000000..fab6e2e45 --- /dev/null +++ b/fjs/fsm/types.ts @@ -0,0 +1,18 @@ +/** + * Types for the finite-state-machine grammar and its compiled DFA. + * + * @module + */ + +import type { List } from '../types/list/types.ts' +import type { ByteSet } from '../types/byte_set/types.ts' +import type { StringMap } from '../types/object/types.ts' +import type { RangeMapArray } from '../types/range_map/types.ts' + +/** A transition rule: source state, input bytes, target state. */ +export type _Rule = readonly [string, ByteSet, string] + +export type Grammar = List<_Rule> + +/** The compiled automaton: each state's byte-range transition table. */ +export type _Dfa = StringMap> diff --git a/fjs/js/keywords/module.f.mjs b/fjs/js/keywords/module.f.mjs index b6b1892b8..5f18c639d 100644 --- a/fjs/js/keywords/module.f.mjs +++ b/fjs/js/keywords/module.f.mjs @@ -8,9 +8,6 @@ * keeping a copy, so the sets cannot drift apart. * * @module - * - * @import { Assert } from '../../asserts/types.ts' - * @import { Equal } from '../../types/ts/types.ts' */ /** @@ -49,7 +46,7 @@ export const restrictedNames = /** @type {const} */ (['arguments', 'eval']) * JavaScript that FunctionalScript keeps as a literal keyword. * * The proof verifies this list is exactly the sorted union of the groups, - * and `_KeywordsPinned` ties the two type-level unions together. + * at runtime and at the type level. */ export const keywords = /** @type {const} */ ([ 'arguments', 'await', 'break', 'case', 'catch', 'class', 'const', @@ -61,12 +58,3 @@ export const keywords = /** @type {const} */ ([ 'undefined', 'var', 'void', 'while', 'with', 'yield', ]) -/** - * @typedef {Assert>} _KeywordsPinned - */ diff --git a/fjs/js/keywords/proof.f.mjs b/fjs/js/keywords/proof.f.mjs index ed4678c40..b3f089bfd 100644 --- a/fjs/js/keywords/proof.f.mjs +++ b/fjs/js/keywords/proof.f.mjs @@ -1,9 +1,23 @@ +/** + * @import { Assert } from '../../asserts/types.ts' + * @import { Equal } from '../../types/ts/types.ts' + */ + import { assertEq } from '../../asserts/module.f.mjs' import { keywords, reservedWords, restrictedNames, strictModeReservedWords } from './module.f.mjs' export const proof = { // `keywords` is exactly the sorted union of the groups plus `undefined` aggregate: () => { + /** + * @typedef {Assert>} _KeywordsPinned + */ /** @type {readonly string[]} */ const union = [...reservedWords, ...strictModeReservedWords, ...restrictedNames, 'undefined'] // the names are unique, so the comparator never sees an equal pair diff --git a/fjs/mcp/cas/module.f.mjs b/fjs/mcp/cas/module.f.mjs index e1a692774..3e840e936 100644 --- a/fjs/mcp/cas/module.f.mjs +++ b/fjs/mcp/cas/module.f.mjs @@ -167,17 +167,15 @@ const toJson = stringify(identity) */ const detectDialect = detect([revisionDialect, lockDialect, noteDialect]) -/** @typedef {{ +/** + * Maps a media-type detector verdict to the `cas_get` wire metadata. + * + * @type {(uri: string) => (detected: { readonly length: bigint, readonly mime_type: string, readonly type: 'text' | 'base64' }) => { * readonly length: number * readonly mimeType: string * readonly type: 'text' | 'base64' * readonly uri: string - * }} _Meta */ - -/** - * Maps a media-type detector verdict to the `cas_get` wire metadata. - * - * @type {(uri: string) => (detected: { readonly length: bigint, readonly mime_type: string, readonly type: 'text' | 'base64' }) => _Meta} + * }} */ const toMeta = uri => ({ length, mime_type: mimeType, type }) => ({ length: Number(length), mimeType, type, uri }) diff --git a/fjs/media/html/module.f.mjs b/fjs/media/html/module.f.mjs index ffc2b360c..9ec960250 100644 --- a/fjs/media/html/module.f.mjs +++ b/fjs/media/html/module.f.mjs @@ -22,8 +22,6 @@ import { quotationMark, ampersand, lessThanSign, greaterThanSign } from '../../t const { fromCharCode } = String -/** @typedef {StringMap} _Attributes */ - /** * Void Elements * @@ -91,10 +89,10 @@ const rawMap = n => concat(mr(n)).replaceAll(' flat([[' ', name, '="'], escape(value), ['"']]) -/** @type {(a: _Attributes) => List} */ +/** @type {(a: StringMap) => List} */ const attributes = a => flatMap(attribute)(definedEntries(a)) -/** @type {(e: Element) => readonly [string, _Attributes, readonly Node[]]} */ +/** @type {(e: Element) => readonly [string, StringMap, readonly Node[]]} */ const parseElement = e => { const [tag, item1, ...list] = e return item1 === undefined ? diff --git a/fjs/media/json/schema/module.f.mjs b/fjs/media/json/schema/module.f.mjs index e7707b1ec..bd9e557f2 100644 --- a/fjs/media/json/schema/module.f.mjs +++ b/fjs/media/json/schema/module.f.mjs @@ -31,23 +31,40 @@ import { cmp, toData, unitBit, unknown as top, withoutUnits } from '../../../rtt import { unknown as jsonUnknown } from '../rtti/module.f.mjs' /** @type {() => readonly ['const', typeof unknownConst]} */ -const unknownThunk = () => ['const', unknownConst] +export const _unknownThunk = () => ['const', unknownConst] /** * rtti schema for a JSON Schema (draft 2020-12) document. - * @type {Phantom} - */ -export const unknown = unknownThunk - -/** - * Checked against the un-annotated thunk, so a wrong `_UnknownConst` above - * would be caught here instead of silently trusted via the `Phantom` lie. - * @typedef {Assert>} _UnknownCheck0 + * + * The `$out` half of the `Phantom` is hand-written. The `?` markers are + * required even though `Ts<>` already includes `undefined` in each field type. + * Without `?`, the document type would require all 13 fields to be present in + * every object literal returned by `toJsonSchema`, because TypeScript + * distinguishes "field absent" (`?`) from "field present but undefined" + * (`T | undefined`). JSON Schema objects only include the fields they need, so + * all fields must be optional. `$defs` is an *open* map — an absent entry + * types as `undefined`, so missing-reference handling cannot be skipped. The + * `consistency` proof checks this hand-written type against the un-annotated + * `_unknownThunk`, so a wrong field here is caught instead of silently trusted + * via the `Phantom` lie. + * + * @type {Phantom + * readonly $ref?: Ts + * readonly $defs?: Ts + * readonly type?: Ts + * readonly const?: Ts + * readonly not?: Ts + * readonly anyOf?: Ts + * readonly items?: Ts + * readonly prefixItems?: Ts + * readonly minItems?: Ts + * readonly properties?: Ts + * readonly required?: Ts + * readonly additionalProperties?: Ts + * }>} */ -/** @typedef {Assert>} _UnknownCheck1 */ - -/** A JSON Schema (draft 2020-12) document — the subset of keywords that `toJsonSchema` emits. */ -/** @typedef {Ts} Unknown */ +export const unknown = _unknownThunk const unknownConst = /** @type {const} */ ({ $schema: option(string), @@ -65,34 +82,6 @@ const unknownConst = /** @type {const} */ ({ additionalProperties: option(unknown), }) -/** - * Hand-written base type used as the `$out` annotation on `unknown`. - * - * The `?` markers are required even though `Ts<>` already includes `undefined` - * in each field type. Without `?`, `Unknown = _UnknownConst` would require all - * 12 fields to be present in every object literal returned by `toJsonSchema`, - * because TypeScript distinguishes "field absent" (`?`) from "field present but - * undefined" (`T | undefined`). JSON Schema objects only include the fields - * they need, so all fields must be optional. `$defs` is an *open* map — an - * absent entry types as `undefined`, so missing-reference handling cannot be - * skipped. - * @typedef {{ - * readonly $schema?: Ts - * readonly $ref?: Ts - * readonly $defs?: Ts - * readonly type?: Ts - * readonly const?: Ts - * readonly not?: Ts - * readonly anyOf?: Ts - * readonly items?: Ts - * readonly prefixItems?: Ts - * readonly minItems?: Ts - * readonly properties?: Ts - * readonly required?: Ts - * readonly additionalProperties?: Ts - * }} _UnknownConst - */ - const nullBit = unitBit(null) const undefinedBit = unitBit(undefined) const falseBit = unitBit(false) @@ -123,14 +112,14 @@ const refEncode = name => { * own-property only, so a name inherited from `Object.prototype` * (`toString`, `constructor`, …) is still rejected. * - * @type {(rules: RuleSet) => (name: string) => Unknown} + * @type {(rules: RuleSet) => (name: string) => Ts} */ const refSchema = rules => name => { assert(at(name)(rules) !== null, `missing definition: ${name}`) return { $ref: `#/$defs/${refEncode(name)}` } } -/** @type {(rules: RuleSet) => (n: Node) => Unknown} */ +/** @type {(rules: RuleSet) => (n: Node) => Ts} */ const nodeSchema = rules => n => typeof n === 'string' ? refSchema(rules)(n) : unionSchema(rules)(n) @@ -140,20 +129,20 @@ const nodeSchema = rules => n => * * @template T * @param {KindSet | undefined} k - * @param {Unknown} whole - * @param {(v: T) => Unknown} item - * @returns {readonly Unknown[]} + * @param {Ts} whole + * @param {(v: T) => Ts} item + * @returns {readonly Ts[]} */ const kindSchemas = (k, whole, item) => k === undefined ? [] : k === true ? [whole] : k.map(item) -/** @type {(v: boolean | number | string | null) => Unknown} */ +/** @type {(v: boolean | number | string | null) => Ts} */ const constSchema = v => ({ const: v }) /** bigint consts are represented as numbers (lossy for |value| > MAX_SAFE_INTEGER) */ -/** @type {(v: bigint) => Unknown} */ +/** @type {(v: bigint) => Ts} */ const bigintConstSchema = v => ({ const: Number(v) }) /** @@ -161,7 +150,7 @@ const bigintConstSchema = v => ({ const: Number(v) }) * value is `undefined`, hence `{ "not": {} }` — and both boolean bits * together are the `boolean` type with no special-case rule. * - * @type {(bits: number) => readonly Unknown[]} + * @type {(bits: number) => readonly Ts[]} */ const unitSchemas = bits => [ ...((bits & nullBit) === 0 ? [] : [constSchema(null)]), @@ -194,7 +183,7 @@ const minLength = rules => prefix => * `minItems` already. Both are the object side's `required` / * {@link stripUndefined} pair, one kind over. * - * @type {(rules: RuleSet) => (p: ArraySet) => Unknown} + * @type {(rules: RuleSet) => (p: ArraySet) => Ts} */ const arraySetSchema = rules => p => { const minItems = minLength(rules)(p.prefix) @@ -236,7 +225,7 @@ const stripUndefined = n => * No `rest` leaves the other keys unconstrained (lenient), matching rtti's * open-struct validation semantics. * - * @type {(rules: RuleSet) => (p: ObjectSet) => Unknown} + * @type {(rules: RuleSet) => (p: ObjectSet) => Ts} */ const objectSetSchema = rules => p => { const ents = definedEntries(p.props) @@ -255,7 +244,7 @@ const objectSetSchema = rules => p => { /** @type {(u: UnionSet) => boolean} */ const isTop = u => cmp([{}, u])([{}, top]) === 0 -/** @type {(rules: RuleSet) => (u: UnionSet) => Unknown} */ +/** @type {(rules: RuleSet) => (u: UnionSet) => Ts} */ const unionSchema = rules => u => { if (isTop(u)) { return {} } const members = [ @@ -282,7 +271,7 @@ const unionSchema = rules => u => { * and are JSON Pointer-escaped, then percent-encoded, for the `$ref` * fragment. A reference naming a missing definition panics. * - * @type {(data: Data) => Unknown} + * @type {(data: Data) => Ts} */ export const dataToJsonSchema = ([rules, entry]) => { const ruleEntries = definedEntries(rules) @@ -321,6 +310,6 @@ export const dataToJsonSchema = ([rules, entry]) => { * duplicates collapse — so structurally different but equivalent thunk * schemas produce the same JSON Schema. * - * @type {(rtti: RttiType) => Unknown} + * @type {(rtti: RttiType) => Ts} */ export const toJsonSchema = rtti => dataToJsonSchema(toData(rtti)) diff --git a/fjs/media/json/schema/proof.f.mjs b/fjs/media/json/schema/proof.f.mjs index 041bbf393..fa897022b 100644 --- a/fjs/media/json/schema/proof.f.mjs +++ b/fjs/media/json/schema/proof.f.mjs @@ -1,65 +1,51 @@ /** - * @import { Unknown } from './module.f.mjs' + * @import { Ts, Check } from '../../../rtti/ts/types.ts' + * @import { Assert } from '../../../asserts/types.ts' * @import { Data } from '../../../rtti/data/types.ts' */ import { boolean, number, string, bigint, never, unknown, array, open, record, or, option } from '../../../rtti/module.f.mjs' import { stringify } from '../module.f.mjs' -import { dataToJsonSchema, toJsonSchema, unknown as schemaUnknown } from './module.f.mjs' +import { _unknownThunk, dataToJsonSchema, toJsonSchema, unknown as schemaUnknown } from './module.f.mjs' import { unitBit } from '../../../rtti/data/module.f.mjs' import { assert, assertEq } from '../../../asserts/module.f.mjs' -/** @type {(v: Unknown) => string} */ +/** @type {(v: Ts) => string} */ const serialize = v => stringify(e => e)(v) -/** @type {(rtti: Parameters[0], expected: Unknown) => () => void} */ +/** @type {(rtti: Parameters[0], expected: Ts) => () => void} */ const eq = (rtti, expected) => () => { const result = serialize(toJsonSchema(rtti)) const exp = serialize(expected) assertEq(result, exp, [result, exp]) } -/** @type {(data: Data, expected: Unknown) => () => void} */ +/** @type {(data: Data, expected: Ts) => () => void} */ const eqData = (data, expected) => () => { const result = serialize(dataToJsonSchema(data)) const exp = serialize(expected) assertEq(result, exp, [result, exp]) } -/** A recursive list: `type _List = readonly _List[]`. */ -/** @typedef {() => readonly ['array', _List]} _List */ -/** @type {_List} */ -const list = () => ['array', list] - -/** Mutual recursion through a container. */ -/** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ -/** @typedef {() => readonly ['array', _Tree]} _Forest */ -/** @type {_Tree} */ -const tree = () => ['or', number, forest] -/** @type {_Forest} */ -const forest = () => ['array', tree] - -/** The recursive revision lock schema. Its cycle closes through the - * anonymous `or` thunk, which becomes the (empty-string-named) rule. */ -/** @typedef {() => readonly ['record', () => readonly ['or', typeof string, _Lock]]} _Lock */ -/** @type {_Lock} */ -const lock = () => ['record', or(string, lock)] - -/** Self-recursive record. */ -/** @typedef {() => readonly ['record', _Rec]} _Rec */ -/** @type {_Rec} */ -const rec = () => ['record', rec] - const listRef = /** @type {const} */ ({ $ref: '#/$defs/list' }) const treeRef = /** @type {const} */ ({ $ref: '#/$defs/tree' }) -/** @type {Unknown} */ +/** @type {Ts} */ const listDef = { type: 'array', items: listRef } -/** @type {Unknown} */ +/** @type {Ts} */ const treeDef = { anyOf: [{ type: 'number' }, { type: 'array', items: treeRef }] } export const proof = { + /** + * The hand-written `$out` on `unknown` matches the real thunk — checked + * against the un-annotated `_unknownThunk`, so a wrong field there is + * caught instead of silently trusted via the `Phantom` lie. + */ + consistency: () => { + /** @typedef {Assert, typeof _unknownThunk>>} _UnknownCheck0 */ + /** @typedef {Assert, typeof schemaUnknown>>} _UnknownCheck1 */ + }, tag0: { boolean: eq(boolean, { type: 'boolean' }), number: eq(number, { type: 'number' }), @@ -249,39 +235,88 @@ export const proof = { }, }, recursion: { - selfList: eq(list, { ...listRef, $defs: { list: listDef } }), - mutualEntry: eq(tree, { ...treeRef, $defs: { tree: treeDef } }), - mutualInline: eq(forest, { type: 'array', items: treeRef, $defs: { tree: treeDef } }), - recursiveUnion: eq(or(number, list), { - anyOf: [{ type: 'number' }, { type: 'array', items: listRef }], - $defs: { list: listDef }, - }), - recursiveRecord: eq(rec, { - $ref: '#/$defs/rec', - $defs: { rec: { type: 'object', additionalProperties: { $ref: '#/$defs/rec' } } }, - }), - optionalRecursiveProperty: eq(/** @type {const} */ ({ p: option(list) }), { - type: 'object', - properties: { p: { type: 'array', items: listRef } }, - additionalProperties: { not: {} }, - $defs: { list: listDef }, - }), - revisionLock: eq(lock, { - type: 'object', - additionalProperties: { $ref: '#/$defs/' }, - $defs: { - '': { - anyOf: [ - { type: 'string' }, - { type: 'object', additionalProperties: { $ref: '#/$defs/' } }, - ], + selfList: () => { + /** A recursive list: `type _List = readonly _List[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] + eq(list, { ...listRef, $defs: { list: listDef } })() + }, + mutualEntry: () => { + /** Mutual recursion through a container. */ + /** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ + /** @typedef {() => readonly ['array', _Tree]} _Forest */ + /** @type {_Tree} */ + const tree = () => ['or', number, forest] + /** @type {_Forest} */ + const forest = () => ['array', tree] + eq(tree, { ...treeRef, $defs: { tree: treeDef } })() + }, + mutualInline: () => { + /** Mutual recursion through a container. */ + /** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ + /** @typedef {() => readonly ['array', _Tree]} _Forest */ + /** @type {_Tree} */ + const tree = () => ['or', number, forest] + /** @type {_Forest} */ + const forest = () => ['array', tree] + eq(forest, { type: 'array', items: treeRef, $defs: { tree: treeDef } })() + }, + recursiveUnion: () => { + /** A recursive list: `type _List = readonly _List[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] + eq(or(number, list), { + anyOf: [{ type: 'number' }, { type: 'array', items: listRef }], + $defs: { list: listDef }, + })() + }, + recursiveRecord: () => { + /** Self-recursive record. */ + /** @typedef {() => readonly ['record', _Rec]} _Rec */ + /** @type {_Rec} */ + const rec = () => ['record', rec] + eq(rec, { + $ref: '#/$defs/rec', + $defs: { rec: { type: 'object', additionalProperties: { $ref: '#/$defs/rec' } } }, + })() + }, + optionalRecursiveProperty: () => { + /** A recursive list: `type _List = readonly _List[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] + eq(/** @type {const} */ ({ p: option(list) }), { + type: 'object', + properties: { p: { type: 'array', items: listRef } }, + additionalProperties: { not: {} }, + $defs: { list: listDef }, + })() + }, + revisionLock: () => { + /** The recursive revision lock schema. Its cycle closes through the + * anonymous `or` thunk, which becomes the (empty-string-named) rule. */ + /** @typedef {() => readonly ['record', () => readonly ['or', typeof string, _Lock]]} _Lock */ + /** @type {_Lock} */ + const lock = () => ['record', or(string, lock)] + eq(lock, { + type: 'object', + additionalProperties: { $ref: '#/$defs/' }, + $defs: { + '': { + anyOf: [ + { type: 'string' }, + { type: 'object', additionalProperties: { $ref: '#/$defs/' } }, + ], + }, }, - }, - }), + })() + }, sharedNonRecursive: () => { // a shared, non-recursive definition is inlined at each use — no `$defs` const person = /** @type {const} */ ({ name: string }) - /** @type {Unknown} */ + /** @type {Ts} */ const personSchema = { type: 'object', properties: { name: { type: 'string' } }, diff --git a/fjs/media/revision/proof.f.mjs b/fjs/media/revision/proof.f.mjs index 5ed61e3da..dc7a7f9b3 100644 --- a/fjs/media/revision/proof.f.mjs +++ b/fjs/media/revision/proof.f.mjs @@ -1,10 +1,12 @@ /** + * @import { Assert } from '../../asserts/types.ts' * @import { Object as JsonObject } from '../json/types.ts' - * @import { LockMap } from './types.ts' + * @import { Check } from '../../rtti/ts/types.ts' + * @import { LockField, LockMap } from './types.ts' */ import { assert, assertEq } from '../../asserts/module.f.mjs' -import { dialect, mediaType, isHash, validate, decodeText, encodeText } from './module.f.mjs' +import { dialect, lock, lockField, mediaType, isHash, validate, decodeText, encodeText } from './module.f.mjs' // Valid cbase32 hashes (round-tripped in fjs/basen/cbase32/proof.f.mjs): single // cbase32 symbols, cheap to write inline here. @@ -34,6 +36,15 @@ const revisionOf = extra => ({ }) export const proof = { + /** + * The hand-written `LockMap`/`LockField` in `./types.ts` are pinned + * against the module's rtti schemas, so the two recursions cannot drift + * apart. + */ + consistency: () => { + /** @typedef {Assert>} _LockMap */ + /** @typedef {Assert>} _LockField */ + }, dialectAndMediaType: () => { assertEq(dialect, 'vnd.fjs.revision') assertEq(mediaType, 'application/vnd.fjs.revision+json') diff --git a/fjs/media/revision/types.ts b/fjs/media/revision/types.ts index f7371652b..cfaa6edc0 100644 --- a/fjs/media/revision/types.ts +++ b/fjs/media/revision/types.ts @@ -4,9 +4,10 @@ * `RevisionError`. * * `LockMap` is written by hand rather than derived, so that the recursion - * reads directly, and is then pinned against the module's rtti schema with - * `Assert>` — the same arrangement the JSON - * data model uses in [`../json/types.ts`](../json/types.ts). `LockSchema` is + * reads directly, and is then pinned against the module's rtti schema by the + * `consistency` proof in [`./proof.f.mjs`](./proof.f.mjs) — the same + * hand-written-plus-pinned arrangement the JSON data model uses in + * [`../json/types.ts`](../json/types.ts). `LockSchema` is * the schema side of the same recursion: `lock` cannot infer its own type * (a `const` may not reference itself in its own initializer), so it carries * this named annotation instead. @@ -14,11 +15,10 @@ * @module */ -import type { Assert } from '../../asserts/types.ts' -import type { Ts, Check } from '../../rtti/ts/types.ts' +import type { Ts } from '../../rtti/ts/types.ts' import type { String as RttiString } from '../../rtti/types.ts' import type { ValidationError } from '../../rtti/common/types.ts' -import type { lock, lockField, revisionSchema } from './module.f.mjs' +import type { revisionSchema } from './module.f.mjs' /** * A set of subject-to-snapshot bindings supplied to dependency resolvers. @@ -34,8 +34,6 @@ export type LockMap = { readonly[subject in string]?: string | LockMap } export type LockSchema = () => readonly['record', () => readonly['or', RttiString, LockSchema]] -type _LockMap = Assert> - /** * A revision's `lock` field: the bindings inline as a {@link LockMap}, or the * cbase32 hash of a `vnd.fjs.lock` blob (`fjs/media/lock`) holding one to @@ -48,8 +46,6 @@ export type LockField = string | LockMap export type LockFieldSchema = () => readonly['or', RttiString, LockSchema] -type _LockField = Assert> - /** The TypeScript type derived from `revisionSchema` — the single source of truth. */ export type Revision = Ts diff --git a/fjs/protocol/mcp/proof.f.mjs b/fjs/protocol/mcp/proof.f.mjs index 6776f2900..10c6f10dd 100644 --- a/fjs/protocol/mcp/proof.f.mjs +++ b/fjs/protocol/mcp/proof.f.mjs @@ -31,15 +31,13 @@ import { // ── Memory mock ──────────────────────────────────────────────────────────────── -/** @typedef {{ +/** @type {{ * readonly next: number * readonly values: { readonly [key: string]: unknown } - * }} _MemoryState */ - -/** @type {_MemoryState} */ + * }} */ const initial = { next: 0, values: {} } -/** @type {MemOperationMap} */ +/** @type {MemOperationMap} */ const mock = { memCreate: value => state => { const id = `k${state.next}` @@ -70,8 +68,7 @@ const configNoTools = { ...config, capabilities: {} } /** @type {McpConfig} */ const configTwoVersions = { ...config, protocolVersions: ['2025-06-18', '2024-11-05'] } -/** @typedef {never} _Op */ -/** @type {McpHandlers<_Op>} */ +/** @type {McpHandlers} */ const handlers = { // Echoes a received cursor as `nextCursor` so tests can observe pagination params. toolsList: (/** @type {ToolsListParams} */ p) => @@ -82,8 +79,6 @@ const handlers = { pureOk({ content: [{ type: 'text', text: 'hello' }] }), } -/** @typedef {readonly [unknown, McpSessionState]} _StepResult */ - // Run a memory effect against the mock and unwrap what it answered. The // channel stays generic because nothing here interprets it: a proof has nobody // to report a failure to, so an `error` is a panic and the tests read the `ok`. @@ -99,7 +94,7 @@ const asMemEffect = e => /** @type {Effect} */ (e) // Pairs the last step's response with the session state read back afterwards. // The response is still needed after the read, so it is carried forward in a // history rather than closed over by a nested continuation. -/** @type {(key: Key) => (e: Effect) => Effect} */ +/** @type {(key: Key) => (e: Effect) => Effect} */ const withState = key => e => { const read0 = historyStep(history(e), () => read(key)) // A history holds `ok` values, so `resp` is the response itself rather @@ -109,14 +104,14 @@ const withState = key => e => { } // Run one step from uninitializedState, return [response, newState]. -/** @type {(cfg: McpConfig) => (msg: Unknown) => _StepResult} */ +/** @type {(cfg: McpConfig) => (msg: Unknown) => readonly [unknown, McpSessionState]} */ const step1 = cfg => msg => runMem(asMemEffect(step( create(uninitializedState), key => withState(key)(mcpStep(cfg)(handlers)(key)(msg))))) // Run initialize then a second step, return [response, newState] of the second. -/** @type {(cfg: McpConfig) => (msg1: Unknown) => (msg2: Unknown) => _StepResult} */ +/** @type {(cfg: McpConfig) => (msg1: Unknown) => (msg2: Unknown) => readonly [unknown, McpSessionState]} */ const step2 = cfg => msg1 => msg2 => runMem(asMemEffect(step( create(uninitializedState), @@ -127,7 +122,7 @@ const step2 = cfg => msg1 => msg2 => }))) // Run initialize, notifications/initialized, then a third step; return [response, newState] of the third. -/** @type {(cfg: McpConfig) => (msg1: Unknown) => (msg2: Unknown) => (msg3: Unknown) => _StepResult} */ +/** @type {(cfg: McpConfig) => (msg1: Unknown) => (msg2: Unknown) => (msg3: Unknown) => readonly [unknown, McpSessionState]} */ const step3 = cfg => msg1 => msg2 => msg3 => runMem(asMemEffect(step( create(uninitializedState), @@ -249,15 +244,15 @@ const initMsg = initMsgFor('2024-11-05') const initNotif = { jsonrpc: '2.0', method: 'notifications/initialized' } /** A memory handler that answers as a runner with no such operation. */ -const memNotImplemented = () => (/** @type {_MemoryState} */ state) => +const memNotImplemented = () => (/** @type {typeof initial} */ state) => /** @type {const} */ ([state, error(['notImplemented', 'memRead'])]) // Runs one step against a memory mock with `overrides` applied, from a session // slot created before them so the slot itself always exists. -/** @type {(overrides: Partial>) => (msg: Unknown) => unknown} */ +/** @type {(overrides: Partial>) => (msg: Unknown) => unknown} */ const failingStep = overrides => msg => { const [state, key] = run(mock)(initial)(create(uninitializedState)) - const runner = run(/** @type {MemOperationMap} */ ({ ...mock, ...overrides })) + const runner = run(/** @type {MemOperationMap} */ ({ ...mock, ...overrides })) // A `Handle` answers `Effect<…, Response | null, never>`, so the payload // the runner hands back is the `ok` around the response and the unwrap is // total — the failures these tests inject are the ones `mcpStep` itself diff --git a/fjs/rtti/common/proof.f.mjs b/fjs/rtti/common/proof.f.mjs index f31d8f95e..9f8fa28e4 100644 --- a/fjs/rtti/common/proof.f.mjs +++ b/fjs/rtti/common/proof.f.mjs @@ -7,14 +7,12 @@ import { eachEntry, structSchemaEntries, tupleSchemaEntries, undeclaredMembers } import { error, ok } from '../../types/result/module.f.mjs' import { assert, assertEq, assertStructurallySame } from '../../asserts/module.f.mjs' -/** @typedef {ReadonlyArray} _Entries */ - /** @type {(k: string, v: number) => Result} */ const item = (k, v) => v < 0 ? error({ path: [], message: `negative at ${k}` }) : ok(v * 2) /** Mirrors `parse`'s accumulate step, kept simple (a small test list, not a `List`). */ -/** @type {(acc: _Entries, k: string, v: number) => _Entries} */ +/** @type {(acc: ReadonlyArray, k: string, v: number) => ReadonlyArray} */ const collect = (acc, k, v) => [...acc, [k, v]] export const proof = { diff --git a/fjs/rtti/data/module.f.mjs b/fjs/rtti/data/module.f.mjs index 25f00c18e..1e59efc82 100644 --- a/fjs/rtti/data/module.f.mjs +++ b/fjs/rtti/data/module.f.mjs @@ -20,6 +20,7 @@ * @import { ResultE } from '../common/types.ts' * @import { StringMap } from '../../types/object/types.ts' * @import { ArraySet, Data, KindSet, Node, ObjectSet, RuleSet, UnionSet } from './types.ts' + * @import { _Assumed, _Ctx, _Key, _Keyed, _NodeMap, _State, _Thunk } from './private.ts' */ import { assert, assertNotNullish } from '../../asserts/module.f.mjs' @@ -365,15 +366,11 @@ const objectSet = (props, rest) => { // ── subset ─────────────────────────────────────────────────────────────────── /** The rule sets of the two compared schemas: `[left, right]`. */ -/** @typedef {readonly [RuleSet, RuleSet]} _Ctx */ - /** * Node pairs assumed included while they are being checked — the standard * coinductive treatment of reference cycles, keyed by {@link _Keyed} node * identities. */ -/** @typedef {StringMap>} _Assumed */ - /** * A node with a canonical identity for the coinductive memo: `r:` a * rule reference, `u:` a rule's object read-set (its rest plus @@ -381,8 +378,6 @@ const objectSet = (props, rest) => { * identity (`undefined`) — recursion through it descends its finite tree, * so every cycle still crosses identified pairs and the memo closes it. */ -/** @typedef {readonly [Node, string | undefined]} _Keyed */ - /** * Own-property lookups only: a `RuleSet`/`props` map is a plain object, so * reading through the prototype chain would return `Object.prototype` @@ -585,8 +580,6 @@ const sortedDedup = cmpItem => list => { return sorted.filter((x, i) => i === 0 || cmpItem(sorted[i - 1], x) !== 0) } -/** @typedef {(n: Node) => Node} _NodeMap */ - /** @type {(f: _NodeMap) => (p: ArraySet) => ArraySet} */ const mapArraySet = f => p => ({ prefix: p.prefix.map(f), @@ -695,29 +688,7 @@ const internData = (rules, entry) => [ // ── toData ─────────────────────────────────────────────────────────────────── /** A thunk — the only schema form that can close a reference cycle. */ -/** @typedef {Exclude} _Thunk */ - /** A schema tracked by identity: a thunk or a const container. */ -/** @typedef {Exclude} _Key */ - -/** - * The conversion state, threaded functionally: - * - * - `converting` — thunks whose union is being computed (the recursion stack). - * - `names` — rule names, assigned to a thunk the moment something needs to - * reference it (a cycle, or a deferred merge). - * - `done` — computed unions, memoized by identity. - * - `deferred` — union merges `target ∪= source` that could not run eagerly - * because `source`'s union was not final; resolved by {@link fixpoint}. - * - * @typedef {{ - * readonly converting: readonly _Thunk[] - * readonly names: readonly (readonly [_Thunk, string])[] - * readonly done: readonly (readonly [_Key, UnionSet])[] - * readonly deferred: readonly (readonly [_Thunk, _Thunk])[] - * }} _State - */ - /** * The first value associated with `key` by identity. * diff --git a/fjs/rtti/data/private.ts b/fjs/rtti/data/private.ts new file mode 100644 index 000000000..0233224fa --- /dev/null +++ b/fjs/rtti/data/private.ts @@ -0,0 +1,39 @@ +/** + * Implementation-private types for the RTTI data conversion. + * + * @module + */ + +import type { StringMap } from '../../types/object/types.ts' +import type { Const, Type } from '../types.ts' +import type { Primitive } from '../ts/types.ts' +import type { Node, RuleSet, UnionSet } from './types.ts' + +export type _Ctx = readonly [RuleSet, RuleSet] + +export type _Assumed = StringMap> + +export type _Keyed = readonly [Node, string | undefined] + +export type _NodeMap = (n: Node) => Node + +export type _Thunk = Exclude + +export type _Key = Exclude + +/** + * The conversion state, threaded functionally: + * + * - `converting` — thunks whose union is being computed (the recursion stack). + * - `names` — rule names, assigned to a thunk the moment something needs to + * reference it (a cycle, or a deferred merge). + * - `done` — computed unions, memoized by identity. + * - `deferred` — union merges `target ∪= source` that could not run eagerly + * because `source`'s union was not final; resolved by `fixpoint`. + */ +export type _State = { + readonly converting: readonly _Thunk[] + readonly names: readonly (readonly [_Thunk, string])[] + readonly done: readonly (readonly [_Key, UnionSet])[] + readonly deferred: readonly (readonly [_Thunk, _Thunk])[] +} diff --git a/fjs/rtti/data/proof.f.mjs b/fjs/rtti/data/proof.f.mjs index aa0c1bb9f..105b07414 100644 --- a/fjs/rtti/data/proof.f.mjs +++ b/fjs/rtti/data/proof.f.mjs @@ -24,111 +24,14 @@ import { cmp, equal, never, subset, toData, unitBit, unitList, unknown, validate const assertData = actual => expected => assert(equal(actual)(expected), [actual, expected]) -/** A recursive list: `type _List = readonly _List[]`. */ -/** @typedef {() => readonly ['array', _List]} _List */ -/** @type {_List} */ -const list = () => ['array', list] - -/** Mutual recursion through a container: `_Tree = number | _Forest`, `_Forest = readonly _Tree[]`. */ -/** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ -/** @typedef {() => readonly ['array', _Tree]} _Forest */ -/** @type {_Tree} */ -const tree = () => ['or', number, forest] -/** @type {_Forest} */ -const forest = () => ['array', tree] - -/** A pure `or` self-cycle: `_SelfOr = number | _SelfOr`. */ -/** @typedef {() => readonly ['or', typeof number, _SelfOr]} _SelfOr */ -/** @type {_SelfOr} */ -const selfOr = () => ['or', number, selfOr] - -/** A mutual `or` cycle: `_OrA = _OrB | number`, `_OrB = _OrA | string`. */ -/** @typedef {() => readonly ['or', _OrB, typeof number]} _OrA */ -/** @typedef {() => readonly ['or', _OrA, typeof string]} _OrB */ -/** @type {_OrA} */ -const orA = () => ['or', orB, number] -/** @type {_OrB} */ -const orB = () => ['or', orA, string] - -/** An `or` over a rule that still has pending merges when it is consumed. */ -/** @typedef {() => readonly ['or', typeof string, _Inner]} _Outer */ -/** @typedef {() => readonly ['or', _Outer, _T2]} _Inner */ -/** @typedef {() => readonly ['array', _Inner]} _T2 */ -/** @type {_Outer} */ -const outer = () => ['or', string, inner] -/** @type {_Inner} */ -const inner = () => ['or', outer, t2] -/** @type {_T2} */ -const t2 = () => ['array', inner] - -/** Two `or` operands deferred onto the same target rule. */ -/** @typedef {() => readonly ['array', _Y]} _X */ -/** @typedef {() => readonly ['array', _W]} _Y */ -/** @typedef {() => readonly ['or', _X, _Y, typeof number]} _W */ -/** @type {_X} */ -const x = () => ['array', y] -/** @type {_Y} */ -const y = () => ['array', w] -/** @type {_W} */ -const w = () => ['or', x, y, number] - -/** A cycle whose union is the whole value domain. */ -/** @typedef {() => readonly ['or', typeof unknownRtti, _TopArr]} _TopOr */ -/** @typedef {() => readonly ['array', _TopOr]} _TopArr */ -/** @type {_TopOr} */ -const topOr = () => ['or', unknownRtti, topArr] -/** @type {_TopArr} */ -const topArr = () => ['array', topOr] - -/** Two named rules where only one is referenced by the entry. */ -/** @typedef {() => readonly ['array', _B2]} _B2 */ -/** @typedef {() => readonly ['array', readonly [_A2, _B2]]} _A2 */ -/** @type {_A2} */ -const a2 = () => ['array', [a2, b2]] -/** @type {_B2} */ -const b2 = () => ['array', b2] - -/** A self-recursive record: rest-based object recursion. */ -/** @typedef {() => readonly ['record', _RecordSelf]} _RecordSelf */ -/** @type {_RecordSelf} */ -const recordSelf = () => ['record', recordSelf] - -/** Mutual recursion through object *properties* rather than containers. */ -/** @typedef {() => readonly ['const', { readonly value: typeof number, readonly next: Or }]} _Even */ -/** @typedef {() => readonly ['const', { readonly value: typeof number, readonly next: Or }]} _Odd */ -/** @type {_Even} */ -const even = () => ['const', { value: number, next: option(odd) }] -/** @type {_Odd} */ -const odd = () => ['const', { value: number, next: option(even) }] - -/** @typedef {() => readonly ['array', _Rec]} _Rec */ /** Every call returns a fresh recursive thunk whose function name is `f`. */ -/** @type {() => _Rec} */ const mkRec = () => { + /** @typedef {() => readonly ['array', _Rec]} _Rec */ /** @type {_Rec} */ const f = () => ['array', f] return f } -/** @type {(f: _Rec) => _Rec} */ -const identityRec = f => f -/** A recursive thunk whose function name is the empty string. */ -/** @type {_Rec} */ -const anon = identityRec(() => ['array', anon]) - -/** A cycle through a closed tuple: `_ClosedNode = [number, readonly _ClosedNode[]]`. */ -/** @typedef {() => readonly ['const', readonly [typeof number, _ClosedChildren]]} _ClosedNode */ -/** @typedef {() => readonly ['array', _ClosedNode]} _ClosedChildren */ -/** @type {_ClosedNode} */ -const closedNode = () => ['const', [number, closedChildren]] -/** @type {_ClosedChildren} */ -const closedChildren = () => ['array', closedNode] - -/** A cycle through a struct's stated `rest`. */ -/** @typedef {() => readonly ['rest', { readonly a: typeof number }, _NestedRest]} _NestedRest */ -/** @type {_NestedRest} */ -const nestedRest = () => ['rest', { a: number }, nestedRest] - const tupleNumber = /** @type {const} */ ([number]) const tupleString = /** @type {const} */ ([string]) const tupleNumberNumber = /** @type {const} */ ([number, number]) @@ -262,6 +165,17 @@ export const proof = { // through a bare container: the enclosing thunk is what becomes the // named rule. restRecursion: () => { + /** A cycle through a closed tuple: `_ClosedNode = [number, readonly _ClosedNode[]]`. */ + /** @typedef {() => readonly ['const', readonly [typeof number, _ClosedChildren]]} _ClosedNode */ + /** @typedef {() => readonly ['array', _ClosedNode]} _ClosedChildren */ + /** @type {_ClosedNode} */ + const closedNode = () => ['const', [number, closedChildren]] + /** @type {_ClosedChildren} */ + const closedChildren = () => ['array', closedNode] + /** A cycle through a struct's stated `rest`. */ + /** @typedef {() => readonly ['rest', { readonly a: typeof number }, _NestedRest]} _NestedRest */ + /** @type {_NestedRest} */ + const nestedRest = () => ['rest', { a: number }, nestedRest] assertData(toData(closedNode))([ { closedNode: { @@ -322,6 +236,24 @@ export const proof = { assertData(toData(or(tupleNumber, tupleString)))(toData(or(tupleString, tupleNumber))) }, recursion: () => { + /** A recursive list: `type _List = readonly _List[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] + /** Mutual recursion through a container: `_Tree = number | _Forest`, `_Forest = readonly _Tree[]`. */ + /** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ + /** @typedef {() => readonly ['array', _Tree]} _Forest */ + /** @type {_Tree} */ + const tree = () => ['or', number, forest] + /** @type {_Forest} */ + const forest = () => ['array', tree] + /** Two named rules where only one is referenced by the entry. */ + /** @typedef {() => readonly ['array', _B2]} _B2 */ + /** @typedef {() => readonly ['array', readonly [_A2, _B2]]} _A2 */ + /** @type {_A2} */ + const a2 = () => ['array', [a2, b2]] + /** @type {_B2} */ + const b2 = () => ['array', b2] assertData(toData(list))([{ list: { array: [{ prefix: [], rest: 'list' }] } }, 'list']) assertData(toData(tree))( [{ tree: { number: true, array: [{ prefix: [], rest: 'tree' }] } }, 'tree']) @@ -351,6 +283,44 @@ export const proof = { ]) }, orCycles: () => { + /** A pure `or` self-cycle: `_SelfOr = number | _SelfOr`. */ + /** @typedef {() => readonly ['or', typeof number, _SelfOr]} _SelfOr */ + /** @type {_SelfOr} */ + const selfOr = () => ['or', number, selfOr] + /** A mutual `or` cycle: `_OrA = _OrB | number`, `_OrB = _OrA | string`. */ + /** @typedef {() => readonly ['or', _OrB, typeof number]} _OrA */ + /** @typedef {() => readonly ['or', _OrA, typeof string]} _OrB */ + /** @type {_OrA} */ + const orA = () => ['or', orB, number] + /** @type {_OrB} */ + const orB = () => ['or', orA, string] + /** An `or` over a rule that still has pending merges when it is consumed. */ + /** @typedef {() => readonly ['or', typeof string, _Inner]} _Outer */ + /** @typedef {() => readonly ['or', _Outer, _T2]} _Inner */ + /** @typedef {() => readonly ['array', _Inner]} _T2 */ + /** @type {_Outer} */ + const outer = () => ['or', string, inner] + /** @type {_Inner} */ + const inner = () => ['or', outer, t2] + /** @type {_T2} */ + const t2 = () => ['array', inner] + /** Two `or` operands deferred onto the same target rule. */ + /** @typedef {() => readonly ['array', _Y]} _X */ + /** @typedef {() => readonly ['array', _W]} _Y */ + /** @typedef {() => readonly ['or', _X, _Y, typeof number]} _W */ + /** @type {_X} */ + const x = () => ['array', y] + /** @type {_Y} */ + const y = () => ['array', w] + /** @type {_W} */ + const w = () => ['or', x, y, number] + /** A cycle whose union is the whole value domain. */ + /** @typedef {() => readonly ['or', typeof unknownRtti, _TopArr]} _TopOr */ + /** @typedef {() => readonly ['array', _TopOr]} _TopArr */ + /** @type {_TopOr} */ + const topOr = () => ['or', unknownRtti, topArr] + /** @type {_TopArr} */ + const topArr = () => ['array', topOr] // a pure `or` self-cycle contributes nothing: _X = number | _X is number assertData(toData(selfOr))(toData(number)) // a mutual `or` cycle is the union of the non-cyclic content @@ -369,6 +339,17 @@ export const proof = { assertData(toData(topArr))([{ topOr: unknown }, { array: [{ prefix: [], rest: 'topOr' }] }]) }, intern: () => { + /** A recursive list: `type _List = readonly _List[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] + /** Mutual recursion through a container: `_Tree = number | _Forest`, `_Forest = readonly _Tree[]`. */ + /** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ + /** @typedef {() => readonly ['array', _Tree]} _Forest */ + /** @type {_Tree} */ + const tree = () => ['or', number, forest] + /** @type {_Forest} */ + const forest = () => ['array', tree] // a union equal to a rule's body reads back as a reference, // so `or` is idempotent on recursive schemas assertData(toData(or(list)))(toData(list)) @@ -382,6 +363,27 @@ export const proof = { assertData(toData({ p: or(list) }))(toData({ p: list })) }, alphaEquivalence: () => { + /** A recursive list: `type _List = readonly _List[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] + /** Mutual recursion through a container: `_Tree = number | _Forest`, `_Forest = readonly _Tree[]`. */ + /** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ + /** @typedef {() => readonly ['array', _Tree]} _Forest */ + /** @type {_Tree} */ + const tree = () => ['or', number, forest] + /** @type {_Forest} */ + const forest = () => ['array', tree] + /** Two `or` operands deferred onto the same target rule. */ + /** @typedef {() => readonly ['array', _Y]} _X */ + /** @typedef {() => readonly ['array', _W]} _Y */ + /** @typedef {() => readonly ['or', _X, _Y, typeof number]} _W */ + /** @type {_X} */ + const x = () => ['array', y] + /** @type {_Y} */ + const y = () => ['array', w] + /** @type {_W} */ + const w = () => ['or', x, y, number] // two α-equivalent recursive rules under different names spell // the same set two ways; their union keeps the spelling that // sorts first instead of dropping the mutually-subsumed pair @@ -399,6 +401,12 @@ export const proof = { assertData(toData(or(tree, y)))(toData(tree)) }, names: () => { + /** @typedef {() => readonly ['array', _Rec]} _Rec */ + /** @type {(f: _Rec) => _Rec} */ + const identityRec = f => f + /** A recursive thunk whose function name is the empty string. */ + /** @type {_Rec} */ + const anon = identityRec(() => ['array', anon]) // colliding function names are disambiguated with a counter assertData(toData(/** @type {const} */ ([mkRec(), mkRec()])))([ { @@ -449,6 +457,10 @@ export const proof = { assertData(toData(or(open({ a: number }), open({}))))(toData(open({}))) }, serializable: () => { + /** A recursive list: `type _List = readonly _List[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] const d = toData(or(string, array(number), null)) assertEq( JSON.stringify(d), @@ -460,6 +472,10 @@ export const proof = { }, cmp: { totalOrder: () => { + /** A recursive list: `type _List = readonly _List[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] assertEq(cmp(toData(number))(toData(number)), 0) assertEq(cmp(toData(list))(toData(list)), 0) assert(cmp(toData(null))(toData(true)) < 0) @@ -580,6 +596,13 @@ export const proof = { // form, where hand-written data used to be the only way to reach // either. Both directions of each. rest: () => { + /** A cycle through a closed tuple: `_ClosedNode = [number, readonly _ClosedNode[]]`. */ + /** @typedef {() => readonly ['const', readonly [typeof number, _ClosedChildren]]} _ClosedNode */ + /** @typedef {() => readonly ['array', _ClosedNode]} _ClosedChildren */ + /** @type {_ClosedNode} */ + const closedNode = () => ['const', [number, closedChildren]] + /** @type {_ClosedChildren} */ + const closedChildren = () => ['array', closedNode] // closed is included in open, never the other way round assert(subset(toData(tupleNumber))(toData(open(tupleNumber)))) assert(!subset(toData(open(tupleNumber)))(toData(tupleNumber))) @@ -648,6 +671,17 @@ export const proof = { assert(subset(toData({ toString: /** @type {const} */ (42) }))(toData({ toString: number }))) }, recursion: () => { + /** A recursive list: `type _List = readonly _List[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] + /** Mutual recursion through a container: `_Tree = number | _Forest`, `_Forest = readonly _Tree[]`. */ + /** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ + /** @typedef {() => readonly ['array', _Tree]} _Forest */ + /** @type {_Tree} */ + const tree = () => ['or', number, forest] + /** @type {_Forest} */ + const forest = () => ['array', tree] assert(subset(toData(list))(toData(list))) assert(subset(toData(forest))(toData(tree))) assert(!subset(toData(tree))(toData(forest))) @@ -655,6 +689,17 @@ export const proof = { assert(subset(toData(array(neverRtti)))(toData(list))) }, mixedObjectRecursion: () => { + /** A self-recursive record: rest-based object recursion. */ + /** @typedef {() => readonly ['record', _RecordSelf]} _RecordSelf */ + /** @type {_RecordSelf} */ + const recordSelf = () => ['record', recordSelf] + /** Mutual recursion through object *properties* rather than containers. */ + /** @typedef {() => readonly ['const', { readonly value: typeof number, readonly next: Or }]} _Even */ + /** @typedef {() => readonly ['const', { readonly value: typeof number, readonly next: Or }]} _Odd */ + /** @type {_Even} */ + const even = () => ['const', { value: number, next: option(odd) }] + /** @type {_Odd} */ + const odd = () => ['const', { value: number, next: option(even) }] // rest-based and property-based object recursion compared in one // union used to overflow the stack: the synthesized `rest ∪ // undefined` read-sets never reached the coinductive memo @@ -794,6 +839,21 @@ export const proof = { assertEq(vs({ a: 1, b: 2 })[0], 'error') }, recursion: () => { + /** A recursive list: `type _List = readonly _List[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] + /** Mutual recursion through a container: `_Tree = number | _Forest`, `_Forest = readonly _Tree[]`. */ + /** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ + /** @typedef {() => readonly ['array', _Tree]} _Forest */ + /** @type {_Tree} */ + const tree = () => ['or', number, forest] + /** @type {_Forest} */ + const forest = () => ['array', tree] + /** A pure `or` self-cycle: `_SelfOr = number | _SelfOr`. */ + /** @typedef {() => readonly ['or', typeof number, _SelfOr]} _SelfOr */ + /** @type {_SelfOr} */ + const selfOr = () => ['or', number, selfOr] const v = validate(toData(list)) assertEq(v([])[0], 'ok') assertEq(v([[], [[]]])[0], 'ok') diff --git a/fjs/rtti/module.f.mjs b/fjs/rtti/module.f.mjs index b9960f16e..0bcc6206d 100644 --- a/fjs/rtti/module.f.mjs +++ b/fjs/rtti/module.f.mjs @@ -5,18 +5,14 @@ * @module * * @import { Includes } from '../types/array/types.ts' - * @import { Assert } from '../asserts/types.ts' - * @import { Equal } from '../types/ts/types.ts' - * @import { Tag0, Primitive0, _Type0, Bigint, Unknown, Tag1, _MakeType1, _MakeOpen, _MakeRest, Or, Type } from './types.ts' + * @import { Tag0, _Type0, Bigint, Unknown, Tag1, _MakeType1, _MakeOpen, _MakeRest, Or, Type } from './types.ts' */ import { includes } from '../types/array/module.f.mjs' -const primitive0List = /** @type {const} */ (['bigint', 'boolean', 'number', 'string']) +export const _primitive0List = /** @type {const} */ (['bigint', 'boolean', 'number', 'string']) -/** @typedef {Assert>} _Primitive0Pinned */ - -export const tag0List = /** @type {const} */ ([...primitive0List, 'unknown']) +export const tag0List = /** @type {const} */ ([..._primitive0List, 'unknown']) const type0 = /** @@ -53,12 +49,10 @@ export const bigint = type0('bigint') */ export const unknown = type0('unknown') -const tag1List = /** @type {const} */ (['array', 'record']) - -/** @typedef {Assert>} _Tag1Pinned */ +export const _tag1List = /** @type {const} */ (['array', 'record']) -/** @type {Includes} */ -export const isTag1 = includes(tag1List) +/** @type {Includes} */ +export const isTag1 = includes(_tag1List) const type1 = /** diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 3baa2e18f..879ecd466 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -46,7 +46,6 @@ * @module * * @import { ConstObject, Info1, Tag1, Type } from '../types.ts' - * @import { Result as CommonResult } from '../../types/result/types.ts' * @import { StringMap } from '../../types/object/types.ts' * @import { List } from '../../types/list/types.ts' * @import { Container, Fits, IsContainer, SchemaEntries, ValidateE, ValidationError, Visitor } from '../common/types.ts' @@ -71,15 +70,11 @@ import { } from '../common/module.f.mjs' import { emptyRest } from '../data/module.f.mjs' -/** @typedef {CommonResult} _ItemResult */ - /** Rebuilds a parsed container from its `[key, parsedValue]` entries. */ -/** @typedef {(entries: ReadonlyArray) => Unknown} _Rebuild */ - -/** @type {_Rebuild} */ +/** @type {(entries: ReadonlyArray) => Unknown} */ const arrayRebuild = entries => entries.map(([, v]) => v) -/** @type {_Rebuild} */ +/** @type {(entries: ReadonlyArray) => Unknown} */ const recordRebuild = entries => Object.fromEntries(entries) /** `eachEntry`'s accumulator seed: entries are consed on in reverse as they parse. */ @@ -117,7 +112,7 @@ const containerParse = /** * @template {Tag1} K * @param {IsContainer>} isContainer - * @param {_Rebuild} rebuild + * @param {(entries: ReadonlyArray) => Unknown} rebuild * @param {(item: Type) => Fits>} restFits * @returns {(item: I) => Parse>} */ @@ -174,7 +169,7 @@ const constContainerParse = * @param {IsContainer} isContainer * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem - * @param {_Rebuild} rebuild + * @param {(entries: ReadonlyArray) => Unknown} rebuild * @param {Fits} fits * @returns {(rtti: T) => Parse} */ @@ -234,7 +229,7 @@ const restContainerParse = * @param {IsContainer} isContainer * @param {SchemaEntries} schemaEntries * @param {(value: C, k: string) => Unknown} getItem - * @param {_Rebuild} rebuild + * @param {(entries: ReadonlyArray) => Unknown} rebuild * @param {(rtti: S, r: Type) => Fits} restFits * @returns {(rtti: S, r: Type) => ValidateE} */ diff --git a/fjs/rtti/parse/proof.f.mjs b/fjs/rtti/parse/proof.f.mjs index 18e9b6325..b40e1e8ea 100644 --- a/fjs/rtti/parse/proof.f.mjs +++ b/fjs/rtti/parse/proof.f.mjs @@ -32,23 +32,6 @@ const unwrap = r => { return /** @type {T} */ (r[1]) } -/** A container that contains itself: `[number, node?]`. */ -/** @typedef {readonly [number, _Node | undefined]} _Node */ - -const _node = () => /** @type {const} */ (['const', [number, option(_node)]]) - -/** @type {Phantom} */ -const node = _node - -/** A struct whose every undeclared key holds another one of these. */ -/** @typedef {() => readonly ['rest', { readonly a: typeof number }, _Nest]} _Nest */ - -/** @type {_Nest} */ -const _nest = () => ['rest', { a: number }, _nest] - -/** @type {Phantom<_Nest, { readonly a: number }>} */ -const nest = _nest - /** @type {(expected: readonly string[]) => (r: readonly [string, unknown]) => void} */ const assertErrorPath = expected => r => { @@ -461,6 +444,11 @@ export const proof = { // would not terminate over a recursive container — see // `../ts/types.ts`); it is the *value* half under test here. recursive: () => { + /** A container that contains itself: `[number, node?]`. */ + /** @typedef {readonly [number, _Node | undefined]} _Node */ + const _node = () => /** @type {const} */ (['const', [number, option(_node)]]) + /** @type {Phantom} */ + const node = _node const p = parse(node) assertStructurallySame(unwrap(p([1])), [1, undefined]) assertStructurallySame(unwrap(p([1, [2]])), [1, [2, undefined]]) @@ -469,6 +457,12 @@ export const proof = { // A cycle through the `rest` itself: every key other than `a` holds // another one of these. recursiveRest: () => { + /** A struct whose every undeclared key holds another one of these. */ + /** @typedef {() => readonly ['rest', { readonly a: typeof number }, _Nest]} _Nest */ + /** @type {_Nest} */ + const _nest = () => ['rest', { a: number }, _nest] + /** @type {Phantom<_Nest, { readonly a: number }>} */ + const nest = _nest const p = parse(nest) assertStructurallySame(unwrap(p({ a: 1, b: { a: 2 } })), { a: 1 }) assertError(p({ a: 1, b: { a: 'x' } })) diff --git a/fjs/rtti/proof.f.mjs b/fjs/rtti/proof.f.mjs index 3eeb00e36..8f65c0498 100644 --- a/fjs/rtti/proof.f.mjs +++ b/fjs/rtti/proof.f.mjs @@ -2,15 +2,13 @@ * @import { StringMap } from '../types/object/types.ts' * @import { Assert } from '../asserts/types.ts' * @import { Equal } from '../types/ts/types.ts' - * @import { Or, Rest, Type1, Unknown } from './types.ts' + * @import { Or, Primitive0, Rest, Tag1, Type1, Unknown } from './types.ts' */ import { assertNotNullish, assertStructurallySame } from '../asserts/module.f.mjs' -import { array, number, open, option, or, record, rest, string, unknown } from './module.f.mjs' +import { _primitive0List, _tag1List, array, number, open, option, or, record, rest, string, unknown } from './module.f.mjs' -/** @typedef {StringMap} _Tests */ - -/** @type {_Tests} */ +/** @type {StringMap} */ const tests = { undefined: [undefined], boolean: [true, false], @@ -58,6 +56,11 @@ const constInference = () => { } export const proof = { + /** The literal tag lists match the type-level unions in `./types.ts`. */ + pinnedLists: () => { + /** @typedef {Assert>} _Primitive0Pinned */ + /** @typedef {Assert>} _Tag1Pinned */ + }, constInference, typeof: Object.fromEntries(Object.entries(tests).map(([k, a]) => [k, assertNotNullish(a).map(v => () => { if (typeof v !== k) { throw `typeof ${v} !== ${k}` } diff --git a/fjs/rtti/ts/module.f.mjs b/fjs/rtti/ts/module.f.mjs index 1a8adc0f1..d41106dd9 100644 --- a/fjs/rtti/ts/module.f.mjs +++ b/fjs/rtti/ts/module.f.mjs @@ -16,6 +16,7 @@ * @import { Printer, StructField } from '../../types/ts/types.ts' * @import { Type } from '../types.ts' * @import { ArraySet, Data, KindSet, Node, ObjectSet, RuleSet, UnionSet } from '../data/types.ts' + * @import { _Ctx } from './private.ts' */ import { assertNotNullish } from '../../asserts/module.f.mjs' @@ -90,14 +91,6 @@ const identifiers = rules => { return result } -/** - * @typedef {{ - * readonly ts: Printer - * readonly ids: readonly (readonly [string, string])[] - * readonly rules: RuleSet - * }} _Ctx - */ - /** @type {(ids: readonly (readonly [string, string])[], name: string) => string | undefined} */ const idOf = (ids, name) => { for (const [k, v] of ids) { diff --git a/fjs/rtti/ts/private.ts b/fjs/rtti/ts/private.ts new file mode 100644 index 000000000..e3891a577 --- /dev/null +++ b/fjs/rtti/ts/private.ts @@ -0,0 +1,14 @@ +/** + * Implementation-private types for the RTTI-to-TypeScript printer. + * + * @module + */ + +import type { Printer } from '../../types/ts/types.ts' +import type { RuleSet } from '../data/types.ts' + +export type _Ctx = { + readonly ts: Printer + readonly ids: readonly (readonly [string, string])[] + readonly rules: RuleSet +} diff --git a/fjs/rtti/ts/proof.f.mjs b/fjs/rtti/ts/proof.f.mjs index a23346d87..1a917e5df 100644 --- a/fjs/rtti/ts/proof.f.mjs +++ b/fjs/rtti/ts/proof.f.mjs @@ -15,67 +15,69 @@ import { dataToTs, printer } from './module.f.mjs' // // Spelled as schema *types* rather than `typeof` a value: these are type-level // facts, and a value existing only to be pointed at is an unused one. -// -// `TupleTs` splits off the trailing run of positions admitting `undefined` and -// renders it optional, which needs a known length. A schema array of non-fixed -// length — what `.map()` produces — has no trailing position to split off, so -// it keeps its element type instead, the homomorphic mapping's answer. Pinned -// because a split that falls back to the empty tuple silently renders such a -// schema `readonly []`, and nothing else here would have caught it. -/** @typedef {Assert, readonly (number | bigint)[]>>} _NonFixedLength */ +const tupleTs = () => { + // `TupleTs` splits off the trailing run of positions admitting `undefined` + // and renders it optional, which needs a known length. A schema array of + // non-fixed length — what `.map()` produces — has no trailing position to + // split off, so it keeps its element type instead, the homomorphic + // mapping's answer. Pinned because a split that falls back to the empty + // tuple silently renders such a schema `readonly []`, and nothing else + // here would have caught it. + /** @typedef {Assert, readonly (number | bigint)[]>>} _NonFixedLength */ -// `option(t)` is `or(t, undefined)`; these are the schema types it produces. -/** @typedef {Or} _OptionBoolean */ -/** @typedef {Or} _OptionString */ + // `option(t)` is `or(t, undefined)`; these are the schema types it produces. + /** @typedef {Or} _OptionBoolean */ + /** @typedef {Or} _OptionString */ -// A variadic tuple is the shape the `length` guard exists for, and the only -// one: its peel *succeeds*, binding the unknown-length prefix to `I`, so -// without the guard the reconstruction flattens it. The others below reach the -// fallback because their peel fails, and are held by that alone. -// -// Asserted as assignability rather than with `Equal<>`. `Equal<>` reports this -// shape as unchanged whether or not the guard is in place — it cannot see the -// difference — so an `Equal<>` pin here passes over the bug it is meant to -// catch. What the flattening actually costs is a string admitted in the number -// prefix, so that is what these state. -/** @typedef {readonly [...(typeof number)[], _OptionString]} _VariadicSchema */ -/** @typedef {Assert ? false : true>} _VariadicPrefixRejectsMixedPrefix */ -/** @typedef {Assert ? true : false>} _VariadicPrefixAdmitsItsOwnShape */ + // A variadic tuple is the shape the `length` guard exists for, and the only + // one: its peel *succeeds*, binding the unknown-length prefix to `I`, so + // without the guard the reconstruction flattens it. The others below reach the + // fallback because their peel fails, and are held by that alone. + // + // Asserted as assignability rather than with `Equal<>`. `Equal<>` reports this + // shape as unchanged whether or not the guard is in place — it cannot see the + // difference — so an `Equal<>` pin here passes over the bug it is meant to + // catch. What the flattening actually costs is a string admitted in the number + // prefix, so that is what these state. + /** @typedef {readonly [...(typeof number)[], _OptionString]} _VariadicSchema */ + /** @typedef {Assert ? false : true>} _VariadicPrefixRejectsMixedPrefix */ + /** @typedef {Assert ? true : false>} _VariadicPrefixAdmitsItsOwnShape */ -// A rest element after a fixed prefix is the same shape from the other side, -// and is held for the same reason: `length` is `number`, so the mapping stands. -// -// This row and `_NonFixedLength` document intent rather than discriminate a -// mechanism. The guard and the fallback both answer `M` for these two shapes, -// so neither single mutation moves them — only removing both at once does. -// The rows that pin one mechanism each are `_VariadicPrefixRejectsMixedPrefix` -// (the guard), `_OptionalMember` (the fallback) and -// `_UnionKeepsBranchCorrelation` (the distribution). -/** @typedef {Assert, readonly [number, ...string[]]>>} _RestTuple */ + // A rest element after a fixed prefix is the same shape from the other side, + // and is held for the same reason: `length` is `number`, so the mapping stands. + // + // This row and `_NonFixedLength` document intent rather than discriminate a + // mechanism. The guard and the fallback both answer `M` for these two shapes, + // so neither single mutation moves them — only removing both at once does. + // The rows that pin one mechanism each are `_VariadicPrefixRejectsMixedPrefix` + // (the guard), `_OptionalMember` (the fallback) and + // `_UnionKeepsBranchCorrelation` (the distribution). + /** @typedef {Assert, readonly [number, ...string[]]>>} _RestTuple */ -// A schema whose own tuple type already marks a member optional is held by the -// *fallback* rather than the length guard: its length is `1 | 2`, not `number`, -// so it reaches the split, where the peel needs a required last element and -// finds none. An optional position is what this transform produces, so one the -// caller wrote is already in the target form and the mapping stands. -/** @typedef {Assert, readonly [number, string?]>>} _OptionalMember */ + // A schema whose own tuple type already marks a member optional is held by the + // *fallback* rather than the length guard: its length is `1 | 2`, not `number`, + // so it reaches the split, where the peel needs a required last element and + // finds none. An optional position is what this transform produces, so one the + // caller wrote is already in the target form and the mapping stands. + /** @typedef {Assert, readonly [number, string?]>>} _OptionalMember */ -// A union of tuple schemas is split per member, not once across the union. -// Splitting the union lets the two halves distribute independently and the -// spread then pairs every prefix with every suffix, so `[number, boolean]` — -// A's prefix with B's suffix — would pass. Assignability again: this is a -// statement about which values the union admits. -/** @typedef {readonly [typeof number, _OptionString]} _BranchA */ -/** @typedef {readonly [typeof string, _OptionBoolean, _OptionNumber]} _BranchB */ -/** @typedef {Or} _OptionNumber */ -/** @typedef {Assert ? false : true>} _UnionKeepsBranchCorrelation */ -/** @typedef {Assert ? true : false>} _UnionAdmitsItsOwnBranches */ + // A union of tuple schemas is split per member, not once across the union. + // Splitting the union lets the two halves distribute independently and the + // spread then pairs every prefix with every suffix, so `[number, boolean]` — + // A's prefix with B's suffix — would pass. Assignability again: this is a + // statement about which values the union admits. + /** @typedef {readonly [typeof number, _OptionString]} _BranchA */ + /** @typedef {readonly [typeof string, _OptionBoolean, _OptionNumber]} _BranchB */ + /** @typedef {Or} _OptionNumber */ + /** @typedef {Assert ? false : true>} _UnionKeepsBranchCorrelation */ + /** @typedef {Assert ? true : false>} _UnionAdmitsItsOwnBranches */ -/** @typedef {Assert, readonly [number, bigint, (boolean | undefined)?, (string | undefined)?]>>} _OptionalTail */ + /** @typedef {Assert, readonly [number, bigint, (boolean | undefined)?, (string | undefined)?]>>} _OptionalTail */ -// Only the *trailing* run: TypeScript forbids a required element after an -// optional one, so an interior position that admits `undefined` stays required. -/** @typedef {Assert, readonly [string | undefined, number]>>} _InteriorStaysRequired */ + // Only the *trailing* run: TypeScript forbids a required element after an + // optional one, so an interior position that admits `undefined` stays required. + /** @typedef {Assert, readonly [string | undefined, number]>>} _InteriorStaysRequired */ +} const toTs = printer() @@ -100,43 +102,8 @@ const eqData = (data, expected) => { assertEq(result, exp, [result, exp]) } -/** A recursive list: `type list = readonly list[]`. */ -/** @typedef {() => readonly ['array', _List]} _List */ -/** @type {_List} */ -const list = () => ['array', list] - -/** Mutual recursion through a container. */ -/** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ -/** @typedef {() => readonly ['array', _Tree]} _Forest */ -/** @type {_Tree} */ -const tree = () => ['or', number, forest] -/** @type {_Forest} */ -const forest = () => ['array', tree] - -/** A cycle closing through an anonymous `or` thunk — an empty rule name. */ -/** @typedef {() => readonly ['record', () => readonly ['or', typeof string, _Lock]]} _Lock */ -/** @type {_Lock} */ -const lock = () => ['record', or(string, lock)] - -/** A recursive rule whose function name is the predefined type name `string`. */ -/** @typedef {() => readonly ['array', _StringNamed]} _StringNamed */ -/** @type {{ readonly string: _StringNamed }} */ -const stringNamedHolder = { string: () => ['array', stringNamedHolder.string] } -const stringNamed = stringNamedHolder.string - -/** A recursive rule whose function name is `T0` — the first generated identifier. */ -/** @typedef {() => readonly ['array', _T0Named]} _T0Named */ -/** @type {{ readonly T0: _T0Named }} */ -const t0NamedHolder = { T0: () => ['array', t0NamedHolder.T0] } -const t0Named = t0NamedHolder.T0 - -/** A recursive rule whose function name is the reserved word `if`. */ -/** @typedef {() => readonly ['array', _IfNamed]} _IfNamed */ -/** @type {{ readonly if: _IfNamed }} */ -const ifNamedHolder = { if: () => ['array', ifNamedHolder.if] } -const ifNamed = ifNamedHolder.if - export const proof = { + tupleTs, tag0: { boolean: () => eq(boolean, 'boolean'), number: () => eq(number, 'number'), @@ -269,17 +236,36 @@ export const proof = { }, recursion: { selfList: () => { + /** A recursive list: `type list = readonly list[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] eq(list, 'list') eqData(toData(list), [[['list', 'readonly(list)[]']], 'list']) }, mutual: () => { + /** Mutual recursion through a container. */ + /** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ + /** @typedef {() => readonly ['array', _Tree]} _Forest */ + /** @type {_Tree} */ + const tree = () => ['or', number, forest] + /** @type {_Forest} */ + const forest = () => ['array', tree] eqData(toData(tree), [[['tree', 'number|readonly(tree)[]']], 'tree']) eqData(toData(forest), [[['tree', 'number|readonly(tree)[]']], 'readonly(tree)[]']) }, recursiveUnion: () => { + /** A recursive list: `type list = readonly list[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] eqData(toData(or(number, list)), [[['list', 'readonly(list)[]']], 'number|readonly(list)[]']) }, mutable: () => { + /** A recursive list: `type list = readonly list[]`. */ + /** @typedef {() => readonly ['array', _List]} _List */ + /** @type {_List} */ + const list = () => ['array', list] const [defs, entry] = dataToTs(true)(toData(list)) assertEq(JSON.stringify([defs, entry]), JSON.stringify([[['list', '(list)[]']], 'list'])) }, @@ -287,6 +273,10 @@ export const proof = { identifiers: { // the empty rule name is not an identifier — generated `T0` emptyName: () => { + /** A cycle closing through an anonymous `or` thunk — an empty rule name. */ + /** @typedef {() => readonly ['record', () => readonly ['or', typeof string, _Lock]]} _Lock */ + /** @type {_Lock} */ + const lock = () => ['record', or(string, lock)] eqData(toData(lock), [ [['T0', 'string|{readonly[k in string]?:T0}']], '{readonly[k in string]?:T0}', @@ -294,10 +284,20 @@ export const proof = { }, // a predefined type name cannot name an alias — generated `T0` predefinedName: () => { + /** A recursive rule whose function name is the predefined type name `string`. */ + /** @typedef {() => readonly ['array', _StringNamed]} _StringNamed */ + /** @type {{ readonly string: _StringNamed }} */ + const stringNamedHolder = { string: () => ['array', stringNamedHolder.string] } + const stringNamed = stringNamedHolder.string eqData(toData(stringNamed), [[['T0', 'readonly(T0)[]']], 'T0']) }, // reserved words cannot name an alias either — generated `T0` reservedName: () => { + /** A recursive rule whose function name is the reserved word `if`. */ + /** @typedef {() => readonly ['array', _IfNamed]} _IfNamed */ + /** @type {{ readonly if: _IfNamed }} */ + const ifNamedHolder = { if: () => ['array', ifNamedHolder.if] } + const ifNamed = ifNamedHolder.if eqData(toData(ifNamed), [[['T0', 'readonly(T0)[]']], 'T0']) }, typeOperatorName: () => { @@ -313,6 +313,15 @@ export const proof = { }, // a generated identifier skips names already kept generatedCollision: () => { + /** A recursive rule whose function name is `T0` — the first generated identifier. */ + /** @typedef {() => readonly ['array', _T0Named]} _T0Named */ + /** @type {{ readonly T0: _T0Named }} */ + const t0NamedHolder = { T0: () => ['array', t0NamedHolder.T0] } + const t0Named = t0NamedHolder.T0 + /** A cycle closing through an anonymous `or` thunk — an empty rule name. */ + /** @typedef {() => readonly ['record', () => readonly ['or', typeof string, _Lock]]} _Lock */ + /** @type {_Lock} */ + const lock = () => ['record', or(string, lock)] eqData(toData(/** @type {const} */ ([t0Named, lock])), [ [['T1', 'string|{readonly[k in string]?:T1}'], ['T0', 'readonly(T0)[]']], 'readonly[T0,{readonly[k in string]?:T1}]', diff --git a/fjs/rtti/validate/proof.f.mjs b/fjs/rtti/validate/proof.f.mjs index 4d773bf23..9ef9c7523 100644 --- a/fjs/rtti/validate/proof.f.mjs +++ b/fjs/rtti/validate/proof.f.mjs @@ -41,164 +41,167 @@ const p = t => /** @type {any} */ (parse(t)) const d = t => dataValidate(toData(t)) /** - * A rest that is its own container, so nothing about it is inline: the - * conversion keeps `rest: "recursiveRest"` rather than recognizing that no - * finite array inhabits it, and every reader accepts a hole past the prefix - * accordingly. It is one of the two rests {@link emptyRests} must *not* - * recognize. + * The acceptance table. Rows cover both container kinds, the closed default + * and a stated rest on both, the short-array rule, primitives, `or`, and + * misses — every reader of a schema has to answer them the same way. Built by + * a thunk so the recursive schemas it needs can carry function-local typedefs. * - * @typedef {() => readonly ['rest', readonly [_RecursiveRest], typeof never]} _RecursiveRest + * @type {() => readonly (readonly [Type, Unknown])[]} */ +const rows = () => { + /** + * A rest that is its own container, so nothing about it is inline: the + * conversion keeps `rest: "recursiveRest"` rather than recognizing that no + * finite array inhabits it, and every reader accepts a hole past the prefix + * accordingly. It is one of the two rests {@link emptyRests} must *not* + * recognize. + * + * @typedef {() => readonly ['rest', readonly [_RecursiveRest], typeof never]} _RecursiveRest + */ -/** @type {_RecursiveRest} */ -const recursiveRest = () => ['rest', [recursiveRest], never] + /** @type {_RecursiveRest} */ + const recursiveRest = () => ['rest', [recursiveRest], never] -/** - * The other one: a pure `or` cycle. `toData(orCycleA)` **is** `never`, yet as a - * rest it converts to a reference and stays, so a test on the rest's own - * canonical data would answer the opposite of the criterion. - * - * @typedef {() => readonly ['or', _OrCycleB]} _OrCycleA - * @typedef {() => readonly ['or', _OrCycleA]} _OrCycleB - */ + /** + * The other one: a pure `or` cycle. `toData(orCycleA)` **is** `never`, yet as a + * rest it converts to a reference and stays, so a test on the rest's own + * canonical data would answer the opposite of the criterion. + * + * @typedef {() => readonly ['or', _OrCycleB]} _OrCycleA + * @typedef {() => readonly ['or', _OrCycleA]} _OrCycleB + */ -/** @type {_OrCycleA} */ -const orCycleA = () => ['or', orCycleB] + /** @type {_OrCycleA} */ + const orCycleA = () => ['or', orCycleB] -/** @type {_OrCycleB} */ -const orCycleB = () => ['or', orCycleA] + /** @type {_OrCycleB} */ + const orCycleB = () => ['or', orCycleA] -/** - * Two separately constructed copies of one recursive rule. Converting a rest - * reserves its rule name first, so the container's copy is named `r0` where - * converting the container alone names it `r` — which is what rules `equal` - * out as the comparison behind {@link emptyRests}. - * - * @typedef {() => readonly ['or', undefined, () => readonly ['array', _SelfList]]} _SelfList - */ + /** + * Two separately constructed copies of one recursive rule. Converting a rest + * reserves its rule name first, so the container's copy is named `r0` where + * converting the container alone names it `r` — which is what rules `equal` + * out as the comparison behind {@link emptyRests}. + * + * @typedef {() => readonly ['or', undefined, () => readonly ['array', _SelfList]]} _SelfList + */ -/** @type {_SelfList} */ -const selfList0 = () => ['or', undefined, array(selfList0)] + /** @type {_SelfList} */ + const selfList0 = () => ['or', undefined, array(selfList0)] -/** @type {_SelfList} */ -const selfList1 = () => ['or', undefined, array(selfList1)] + /** @type {_SelfList} */ + const selfList1 = () => ['or', undefined, array(selfList1)] -/** - * The acceptance table. Rows cover both container kinds, the closed default - * and a stated rest on both, the short-array rule, primitives, `or`, and - * misses — every reader of a schema has to answer them the same way. - * - * @type {readonly (readonly [Type, Unknown])[]} - */ -const rows = [ - [number, 42], - [number, '42'], - [string, 42], - [boolean, false], - [bigint, 7n], - [unknown, { a: [1, 'x'] }], - [/** @type {const} */ (42), 42], - [/** @type {const} */ (42), 43], - [array(number), [1, 2, 3]], - [array(number), [1, 'two']], - [array(number), {}], - // an enumerable non-index key is an entry every reader walks, so it is - // held to the element type like any other — and a key is an index only in - // the canonical spelling, whatever `Number` makes of it - [array(number), Object.assign([1], { foo: 2 })], - [array(number), Object.assign([1], { foo: 'x' })], - [array(number), Object.assign([1], { '-1': 'x' })], - [array(number), Object.assign([1], { '01': 'x' })], - // an empty element set is the empty array, not "any number of holes": the - // data form normalizes such a rest away, which leaves the exact-length - // pattern, and the thunk readers bound the length to match - [array(or()), []], - [array(or()), new Array(1)], - [array(number), [, ,]], - [record(number), { a: 1 }], - [record(number), { a: 'one' }], - [record(number), []], - // the closed default, on both kinds - [[/** @type {const} */ (42)], [42, 'extra']], - [[/** @type {const} */ (42)], [42]], - [[/** @type {const} */ (42)], [42, undefined]], - [[/** @type {const} */ (42)], [42, ,]], - [[/** @type {const} */ (42)], Object.assign([42], { foo: 1 })], - [[/** @type {const} */ (42)], []], - [{ a: /** @type {const} */ (42) }, { a: 42, b: 'x' }], - [{ a: /** @type {const} */ (42) }, { a: 42 }], - // a key declared `unknown` is a member the schema has, so the canonical - // form must not drop it the way an `open` struct's is dropped - [{ a: unknown }, { a: 1 }], - [{ a: unknown }, { a: 1, b: 2 }], - // and the same rows under `open`, which is the form that admits them - [open([/** @type {const} */ (42)]), [42, 'extra']], - [open({ a: /** @type {const} */ (42) }), { a: 42, b: 'x' }], - [open([]), [1]], - [open({}), { a: 1 }], - // closedness is about *undeclared* members and leaves the short-array rule - // alone - [[number, option(string)], [42]], - // the rule is per position, not "the last one": every trailing position - // whose set admits `undefined` may be absent, so an array may stop at the - // last required one - [[number, bigint, option(string), option(null)], [2, 4n]], - [[number, bigint, option(string), option(null)], [2, 4n, 'x']], - [[number, bigint, option(string), option(null)], [2, 4n, 'x', null]], - [[number, bigint, option(string), option(null)], [2]], - [[number, bigint, option(string), option(null)], [2, 4n, 5]], - [{ a: number, b: option(string) }, { a: 1 }], - [{ a: number }, { a: 'one' }], - // a hole in a tuple schema is a declared position whose schema is - // `undefined`, so the schema's length is what it declares — the reading - // the data form has always had, and the one `Object.entries` lost - [new Array(1), [1, 2, 3]], - [new Array(1), new Array(1)], - [new Array(1), [undefined]], - [new Array(1), [1]], - [new Array(1), []], - [[, number], [9, 5]], - [[, number], [undefined, 5]], - // and a non-index enumerable own property is no position at all: a tuple - // schema is read by index, so `foo` declares nothing — which leaves a - // value's own `foo` an undeclared member like any other - [Object.assign([number], { foo: string }), [1]], - [Object.assign([number], { foo: string }), Object.assign([1], { foo: 'x' })], - [open(Object.assign([number], { foo: string })), Object.assign([1], { foo: 'x' })], - // a stated rest: what an undeclared member must be - [rest([number], string), [1, 'x', 'y']], - [rest([number], string), [1, 2]], - // a hole past the prefix is no member, so it meets no rest — which is what - // the `| undefined` in the rendered tail says - [rest([number], string), [1, ,]], - // An index the prototype supplies, and a key past the index range, are - // members too — both need in-place mutation to build, so their rows run - // through the same three readers in `../host.proof.mjs`. - [rest({ a: number }, string), { a: 1, b: 'x' }], - [rest({ a: number }, string), { a: 1, b: 2 }], - // a stated rest with nothing to answer for: the struct kind has no length, - // so it fits whatever the rest is - [rest({ a: number }, string), { a: 1 }], - // an unconstrained rest is `open` - [rest([number], unknown), [1, 'x']], - [rest({ a: number }, unknown), { a: 1, b: 'x' }], - // an empty one is the bare form, so the length is bounded again - [rest([number], never), [1, ,]], - [rest([number], or()), [1, ,]], - [rest([number], [or()]), [1, ,]], - [rest([number], [or()]), [1, 2]], - // …and a rest the conversion keeps is not empty, however few values it - // has: these two are the pair that tells the criterion from an emptiness - // analysis - [rest([number], recursiveRest), [1, ,]], - [rest([number], orCycleA), [1, ,]], - [rest([selfList0], [selfList1, never]), [undefined, ,]], - [or(number, string), true], - [or(number, string), 'hello'], - [option(number), undefined], - [option(number), null], - [{ user: { name: string, age: number } }, { user: { name: 'A', age: 'old' } }], -] + return [ + [number, 42], + [number, '42'], + [string, 42], + [boolean, false], + [bigint, 7n], + [unknown, { a: [1, 'x'] }], + [/** @type {const} */ (42), 42], + [/** @type {const} */ (42), 43], + [array(number), [1, 2, 3]], + [array(number), [1, 'two']], + [array(number), {}], + // an enumerable non-index key is an entry every reader walks, so it is + // held to the element type like any other — and a key is an index only in + // the canonical spelling, whatever `Number` makes of it + [array(number), Object.assign([1], { foo: 2 })], + [array(number), Object.assign([1], { foo: 'x' })], + [array(number), Object.assign([1], { '-1': 'x' })], + [array(number), Object.assign([1], { '01': 'x' })], + // an empty element set is the empty array, not "any number of holes": the + // data form normalizes such a rest away, which leaves the exact-length + // pattern, and the thunk readers bound the length to match + [array(or()), []], + [array(or()), new Array(1)], + [array(number), [, ,]], + [record(number), { a: 1 }], + [record(number), { a: 'one' }], + [record(number), []], + // the closed default, on both kinds + [[/** @type {const} */ (42)], [42, 'extra']], + [[/** @type {const} */ (42)], [42]], + [[/** @type {const} */ (42)], [42, undefined]], + [[/** @type {const} */ (42)], [42, ,]], + [[/** @type {const} */ (42)], Object.assign([42], { foo: 1 })], + [[/** @type {const} */ (42)], []], + [{ a: /** @type {const} */ (42) }, { a: 42, b: 'x' }], + [{ a: /** @type {const} */ (42) }, { a: 42 }], + // a key declared `unknown` is a member the schema has, so the canonical + // form must not drop it the way an `open` struct's is dropped + [{ a: unknown }, { a: 1 }], + [{ a: unknown }, { a: 1, b: 2 }], + // and the same rows under `open`, which is the form that admits them + [open([/** @type {const} */ (42)]), [42, 'extra']], + [open({ a: /** @type {const} */ (42) }), { a: 42, b: 'x' }], + [open([]), [1]], + [open({}), { a: 1 }], + // closedness is about *undeclared* members and leaves the short-array rule + // alone + [[number, option(string)], [42]], + // the rule is per position, not "the last one": every trailing position + // whose set admits `undefined` may be absent, so an array may stop at the + // last required one + [[number, bigint, option(string), option(null)], [2, 4n]], + [[number, bigint, option(string), option(null)], [2, 4n, 'x']], + [[number, bigint, option(string), option(null)], [2, 4n, 'x', null]], + [[number, bigint, option(string), option(null)], [2]], + [[number, bigint, option(string), option(null)], [2, 4n, 5]], + [{ a: number, b: option(string) }, { a: 1 }], + [{ a: number }, { a: 'one' }], + // a hole in a tuple schema is a declared position whose schema is + // `undefined`, so the schema's length is what it declares — the reading + // the data form has always had, and the one `Object.entries` lost + [new Array(1), [1, 2, 3]], + [new Array(1), new Array(1)], + [new Array(1), [undefined]], + [new Array(1), [1]], + [new Array(1), []], + [[, number], [9, 5]], + [[, number], [undefined, 5]], + // and a non-index enumerable own property is no position at all: a tuple + // schema is read by index, so `foo` declares nothing — which leaves a + // value's own `foo` an undeclared member like any other + [Object.assign([number], { foo: string }), [1]], + [Object.assign([number], { foo: string }), Object.assign([1], { foo: 'x' })], + [open(Object.assign([number], { foo: string })), Object.assign([1], { foo: 'x' })], + // a stated rest: what an undeclared member must be + [rest([number], string), [1, 'x', 'y']], + [rest([number], string), [1, 2]], + // a hole past the prefix is no member, so it meets no rest — which is what + // the `| undefined` in the rendered tail says + [rest([number], string), [1, ,]], + // An index the prototype supplies, and a key past the index range, are + // members too — both need in-place mutation to build, so their rows run + // through the same three readers in `../host.proof.mjs`. + [rest({ a: number }, string), { a: 1, b: 'x' }], + [rest({ a: number }, string), { a: 1, b: 2 }], + // a stated rest with nothing to answer for: the struct kind has no length, + // so it fits whatever the rest is + [rest({ a: number }, string), { a: 1 }], + // an unconstrained rest is `open` + [rest([number], unknown), [1, 'x']], + [rest({ a: number }, unknown), { a: 1, b: 'x' }], + // an empty one is the bare form, so the length is bounded again + [rest([number], never), [1, ,]], + [rest([number], or()), [1, ,]], + [rest([number], [or()]), [1, ,]], + [rest([number], [or()]), [1, 2]], + // …and a rest the conversion keeps is not empty, however few values it + // has: these two are the pair that tells the criterion from an emptiness + // analysis + [rest([number], recursiveRest), [1, ,]], + [rest([number], orCycleA), [1, ,]], + [rest([selfList0], [selfList1, never]), [undefined, ,]], + [or(number, string), true], + [or(number, string), 'hello'], + [option(number), undefined], + [option(number), null], + [{ user: { name: string, age: number } }, { user: { name: 'A', age: 'old' } }], + ] +} export const proof = { // ── the three properties this module exists for ────────────────────────── @@ -259,7 +262,7 @@ export const proof = { // Acceptance is `parse`'s, exactly: the two readers differ in what a // success carries and in nothing else. sameAcceptanceAsParse: () => { - for (const [t, value] of rows) { + for (const [t, value] of rows()) { const rv = v(t)(value) const rp = p(t)(value) assertEq(rv[0], rp[0], 'validate and parse must agree on acceptance') @@ -279,7 +282,7 @@ export const proof = { // it reports a miss as its own kind-wise failure rather than repeating // `or`'s `no match`. sameAcceptanceInTheDataForm: () => { - for (const [t, value] of rows) { + for (const [t, value] of rows()) { assertEq(d(t)(value)[0], p(t)(value)[0], 'the data form must accept what `parse` accepts') } }, @@ -725,6 +728,17 @@ export const proof = { // `never`'s identity passes the converse. emptyRests: { dropped: () => { + /** + * Two separately constructed copies of one recursive rule — the + * pair behind the name-collision comparison; see the acceptance + * table's own copy for the full story. + * + * @typedef {() => readonly ['or', undefined, () => readonly ['array', _SelfList]]} _SelfList + */ + /** @type {_SelfList} */ + const selfList0 = () => ['or', undefined, array(selfList0)] + /** @type {_SelfList} */ + const selfList1 = () => ['or', undefined, array(selfList1)] for (const r of [never, or(), [or()]]) { assertError(validate(rest([number], r))([42, ,])) } @@ -734,6 +748,24 @@ export const proof = { assertError(v(rest([selfList0], [selfList1, never]))([undefined, ,])) }, kept: () => { + /** + * A rest that is its own container, so nothing about it is + * inline; the acceptance table's copy carries the full story. + * + * @typedef {() => readonly ['rest', readonly [_RecursiveRest], typeof never]} _RecursiveRest + */ + /** @type {_RecursiveRest} */ + const recursiveRest = () => ['rest', [recursiveRest], never] + /** + * The other one: a pure `or` cycle. + * + * @typedef {() => readonly ['or', _OrCycleB]} _OrCycleA + * @typedef {() => readonly ['or', _OrCycleA]} _OrCycleB + */ + /** @type {_OrCycleA} */ + const orCycleA = () => ['or', orCycleB] + /** @type {_OrCycleB} */ + const orCycleB = () => ['or', orCycleA] // A rest the conversion keeps is not empty however few values it // has: `recursiveRest` catches an emptiness analysis that reaches // container cycles, `orCycleA` one that tests the rest's own diff --git a/fjs/sul/level/hash/proof.f.mjs b/fjs/sul/level/hash/proof.f.mjs index 793260fce..7f15100b3 100644 --- a/fjs/sul/level/hash/proof.f.mjs +++ b/fjs/sul/level/hash/proof.f.mjs @@ -7,16 +7,14 @@ import { assert, assertEq, assertNotNullish } from '../../../asserts/module.f.mj import { compress, level3Id } from '../../id/module.f.mjs' import { emptyEncodeState, encode } from './module.f.mjs' -/** @typedef {readonly (readonly [Id, Id, Id, boolean])[]} _NodeList */ - -/** @type {(l: Id, r: Id, m: Id, isSymbol: boolean, s: _NodeList) => _NodeList} */ +/** @type {(l: Id, r: Id, m: Id, isSymbol: boolean, s: readonly (readonly [Id, Id, Id, boolean])[]) => readonly (readonly [Id, Id, Id, boolean])[]} */ const add = (l, r, m, isSymbol, s) => [...s, [l, r, m, isSymbol]] const enc = encode(add) -/** @type {EncodeState<_NodeList>} */ +/** @type {EncodeState} */ const initial = emptyEncodeState([]) // Run a complete valid word from a clean state; throws if no output is produced. -/** @type {(symbols: readonly Id[]) => readonly [Id, _NodeList]} */ +/** @type {(symbols: readonly Id[]) => readonly [Id, readonly (readonly [Id, Id, Id, boolean])[]]} */ const runWord = symbols => { let state = initial for (const s of symbols) { @@ -28,7 +26,7 @@ const runWord = symbols => { } // Every stored triple must satisfy m === compress(l, r). -/** @type {(storage: _NodeList) => void} */ +/** @type {(storage: readonly (readonly [Id, Id, Id, boolean])[]) => void} */ const verifyStorage = storage => { for (const [l, r, m] of storage) { assertEq(m, compress(l, r)) } } diff --git a/fjs/sul/module.f.mjs b/fjs/sul/module.f.mjs index c252b4860..e225314b0 100644 --- a/fjs/sul/module.f.mjs +++ b/fjs/sul/module.f.mjs @@ -15,8 +15,6 @@ import { emptyPipelineState, pipelineStep } from './level/literal/module.f.mjs' import { encode as hashEncode } from './level/hash/module.f.mjs' import { level3Id } from './id/module.f.mjs' -/** @typedef {InternalState} _HashState */ - /** @type {(storage: S) => EncodeState} */ export const emptyEncodeState = storage => [emptyPipelineState, storage, []] @@ -30,14 +28,14 @@ export const encode = add => { const step = hashEncode(add) - /** @typedef {readonly [Id | undefined, S, readonly _HashState[]]} _CascadeResult */ + /** @typedef {readonly [Id | undefined, S, readonly InternalState[]]} _CascadeResult */ // Recursive rather than a `for(;;)` loop: every exit is one of the two // `return`s below, so a bare `for(;;)` picks up a phantom "loop falls // through" branch that V8's coverage instrumentation can never mark // taken — there is no third way out to take it. Recursion has no such // branch to begin with. - /** @type {(id: Id, storage: S, stacks: readonly _HashState[], index: number) => _CascadeResult} */ + /** @type {(id: Id, storage: S, stacks: readonly InternalState[], index: number) => _CascadeResult} */ const cascadeFrom = (id, storage, stacks, index) => { if (index >= stacks.length) { const [, [newStorage, newStack]] = step(id, [storage, []]) @@ -50,7 +48,7 @@ export const encode = : cascadeFrom(out, newStorage, newStacks, index + 1) } - /** @type {(id0: Id, storage0: S, stacks0: readonly _HashState[]) => _CascadeResult} */ + /** @type {(id0: Id, storage0: S, stacks0: readonly InternalState[]) => _CascadeResult} */ const cascade = (id0, storage0, stacks0) => cascadeFrom(id0, storage0, stacks0, 0) /** @type {(bit: bigint, state: EncodeState) => readonly [Id | undefined, EncodeState]} */ diff --git a/fjs/sul/proof.f.mjs b/fjs/sul/proof.f.mjs index 17a6c5f23..d334a50ec 100644 --- a/fjs/sul/proof.f.mjs +++ b/fjs/sul/proof.f.mjs @@ -7,11 +7,9 @@ import { assert, assertEq } from '../asserts/module.f.mjs' import { compress } from './id/module.f.mjs' import { encode, emptyEncodeState } from './module.f.mjs' -/** @typedef {readonly [Id, Id, Id, boolean]} _Merge */ - -/** @type {(bits: readonly bigint[]) => readonly [Id, readonly _Merge[]]} */ +/** @type {(bits: readonly bigint[]) => readonly [Id, readonly (readonly [Id, Id, Id, boolean])[]]} */ const run = bits => { - /** @type {_Merge[]} */ + /** @type {(readonly [Id, Id, Id, boolean])[]} */ const log = [] /** @type {Add} */ const add = (l, r, m, isSymbol) => { log.push([l, r, m, isSymbol]); return null } diff --git a/fjs/text/sgr/module.f.mjs b/fjs/text/sgr/module.f.mjs index 93ec54cc6..8ad134874 100644 --- a/fjs/text/sgr/module.f.mjs +++ b/fjs/text/sgr/module.f.mjs @@ -21,10 +21,6 @@ export const backspace = '\x08' // -/** @typedef {'m'} _End */ - -/** @typedef {(code: number | string) => string} _Csi */ - const begin = '\x1b[' /** @@ -34,7 +30,7 @@ const begin = '\x1b[' * @param end - The final character that indicates the type of sequence. * @returns A function that takes a code (number or string) and returns the complete ANSI escape sequence. * - * @type {(end: _End) => _Csi} + * @type {(end: 'm') => (code: number | string) => string} */ export const csi = end => code => `${begin}${code.toString()}${end}` @@ -43,7 +39,7 @@ export const csi = end => code => * Specialization of CSI for Select Graphic Rendition (SGR) sequences. * https://en.wikipedia.org/wiki/ANSI_escape_code#SGR * - * @type {_Csi} + * @type {(code: number | string) => string} */ export const sgr = csi('m') diff --git a/fjs/text/utf16/module.f.mjs b/fjs/text/utf16/module.f.mjs index e2567bb78..f63e80eff 100644 --- a/fjs/text/utf16/module.f.mjs +++ b/fjs/text/utf16/module.f.mjs @@ -31,12 +31,9 @@ import { isSupplementaryPlane, } from '../code_point/module.f.mjs' -/** - * Optional Utf16State - represents the state of utf16 decoding operation or null. - * - number is used an unsigned integer. - * - * @typedef {number | null} _Utf16State - */ +// The `number | null` state threaded through the decoder below is the UTF-16 +// decoding state: a pending high surrogate as an unsigned integer, or `null` +// when no code unit is pending. /** * The BMP / surrogate / supplementary-plane predicates used below live in @@ -180,7 +177,7 @@ const u16 = i => Number.isInteger(i) && isInU16Range(i) * const [decodedCodePoints, newState] = utf16ByteToCodePointOp(word, state); * ``` * - * @type {StateScan>} + * @type {StateScan>} */ const utf16ByteToCodePointOp = (word, state) => { if (!u16(word)) { @@ -223,7 +220,7 @@ const utf16StateToError = state => state | errorMask * to flag the invalid sequence. The flush itself is `eofFlush` from * `code_point`, shared with UTF-8. * - * @type {(state: _Utf16State) => readonly[List, _Utf16State]} + * @type {(state: number | null) => readonly[List, number | null]} */ const utf16EofToCodePointOp = eofFlush(utf16StateToError) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 28fe762af..ebe07e019 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -274,31 +274,41 @@ type-only and use named `import type { ... }` imports. #### Stage 1 — source restructuring -- [ ] Document the repository-wide prohibition on file-scope JSDoc `@typedef` in +- [x] Document the repository-wide prohibition on file-scope JSDoc `@typedef` in authored `.mjs`; allow function-local typedefs. -- [ ] Migrate existing violations, including authored `.mjs` outside `fjs/` such +- [x] Migrate existing violations, including authored `.mjs` outside `fjs/` such as `todo/proof.f.mjs`. -- [ ] Keep `types.ts` as the public declaration closure; retain/in-line private +- [x] Keep `types.ts` as the public declaration closure; retain/in-line private helpers required by public declarations. -- [ ] Use `private.ts` only where separating implementation-private file-scope +- [x] Use `private.ts` only where separating implementation-private file-scope types improves the design. -- [ ] Preserve the intra-directory dependency direction shown above; move +- [x] Preserve the intra-directory dependency direction shown above; move verification downstream when that is cleaner. -- [ ] Move the `fjs/effects/types.ts` implementation-signature asserts into proof +- [x] Move the `fjs/effects/types.ts` implementation-signature asserts into proof functions in `fjs/effects/proof.f.mjs`. -- [ ] Review recursive cases individually, including `fjs/media/revision` and +- [x] Review recursive cases individually, including `fjs/media/revision` and `fjs/edag`; keep recursive RTTI in `module.f.mjs` when required by layering and move consistency asserts into proof functions. -- [ ] Where useful, split declarative compile-time/runtime constants into a normal - subordinate module such as `meta/module.f.mjs`; do not require it. -- [ ] Preserve leading `_` for private types and private runtime constants. -- [ ] Treat chosen public import-path moves as breaking changes with no +- [x] Where useful, split declarative compile-time/runtime constants into a normal + subordinate module such as `meta/module.f.mjs`; do not require it. The + migration warranted none: every recursive metaprogramming constant + (`fjs/edag`, `fjs/media/json/schema`) reads best staying in its + `module.f.mjs`; the option stays documented in `fjs/AGENTS.md` §3.2. +- [x] Preserve leading `_` for private types and private runtime constants. +- [x] Treat chosen public import-path moves as breaking changes with no compatibility re-exports. -- [ ] Add fixtures/examples covering: public-declaration helpers, optional +- [x] Add fixtures/examples covering: public-declaration helpers, optional `private.ts`, function-local proof typedefs, recursive RTTI kept in `module.f.mjs`, optional `meta/module.f.mjs`, and authored `.mjs` outside - `fjs/`. -- [ ] Update root and `fjs/` `AGENTS.md` policy documentation; rewrite the + `fjs/`. Live modules serve as the examples, cited from `fjs/AGENTS.md` + §3.2: `fjs/types/byte_set/types.ts` (`_Byte` public-closure helper), + `fjs/common/monoid/private.ts` and `fjs/rtti/data/private.ts` + (`private.ts`), `fjs/edag/proof.f.mjs` and `fjs/effects/proof.f.mjs` + (function-local proof typedefs), `fjs/edag/module.f.mjs` and + `fjs/media/json/schema/module.f.mjs` (recursive RTTI kept in place), + `todo/proof.f.mjs` (authored `.mjs` outside `fjs/`); `meta/module.f.mjs` + remains a documented option with no current instance. +- [x] Update root and `fjs/` `AGENTS.md` policy documentation; rewrite the `fjs/fsc/README.md` typedef prescription; delete or narrow the blocked `@internal` TODO; sweep all remaining Markdown documents for file-scope typedef prescriptions and retarget each to the Stage 1 forms. diff --git a/fjs/types/bigfloat/module.f.mjs b/fjs/types/bigfloat/module.f.mjs index cb28340a8..e0726bf16 100644 --- a/fjs/types/bigfloat/module.f.mjs +++ b/fjs/types/bigfloat/module.f.mjs @@ -5,20 +5,11 @@ * * @import { BigFloat, Format } from './types.ts' * @import { Nullable } from '../nullable/types.ts' + * @import { _BigFloatWithRemainder } from './private.ts' */ import { abs, bitLength, mask, sign } from '../bigint/module.f.mjs' -/** - * A magnitude that has been truncated, paired with what was cut off: the exact - * value is `m * 2^e` when `r` is `0n`, and strictly between `m * 2^e` and - * `(m + 1) * 2^e` otherwise. Only `r === 0n` is ever asked, so any non-zero - * `r` — a division remainder, the bits a shift dropped, or both — says the - * same thing. - * - * @typedef {readonly [BigFloat, bigint]} _BigFloatWithRemainder - */ - /** @type {(exp: number) => bigint} */ const twoPow = exp => 1n << BigInt(exp) diff --git a/fjs/types/bigfloat/private.ts b/fjs/types/bigfloat/private.ts new file mode 100644 index 000000000..41fa94f95 --- /dev/null +++ b/fjs/types/bigfloat/private.ts @@ -0,0 +1,16 @@ +/** + * Implementation-private types for the big-float module. + * + * @module + */ + +import type { BigFloat } from './types.ts' + +/** + * A magnitude that has been truncated, paired with what was cut off: the exact + * value is `m * 2^e` when `r` is `0n`, and strictly between `m * 2^e` and + * `(m + 1) * 2^e` otherwise. Only `r === 0n` is ever asked, so any non-zero + * `r` — a division remainder, the bits a shift dropped, or both — says the + * same thing. + */ +export type _BigFloatWithRemainder = readonly [BigFloat, bigint] diff --git a/fjs/types/bigint/proof.f.mjs b/fjs/types/bigint/proof.f.mjs index 7a6fd29e3..3284a7cbb 100644 --- a/fjs/types/bigint/proof.f.mjs +++ b/fjs/types/bigint/proof.f.mjs @@ -139,9 +139,7 @@ const m1023log2 = v => { return result + rem + (v >> rem) } -/** @typedef {(f: (_: bigint) => bigint) => () => void} _Benchmark */ - -/** @type {_Benchmark} */ +/** @type {(f: (_: bigint) => bigint) => () => void} */ const benchmark = f => () => { let e = 1_048_575n let c = 1n << e @@ -160,7 +158,7 @@ const benchmark = f => () => { } -/** @type {_Benchmark} */ +/** @type {(f: (_: bigint) => bigint) => () => void} */ const benchmarkSmall = f => () => { let e = 2_000n let c = 1n << e @@ -211,7 +209,7 @@ export const proof = { // m1023log2, log2, } - const transform = (/** @type {_Benchmark} */ b) => + const transform = (/** @type {(f: (_: bigint) => bigint) => () => void} */ b) => Object.fromEntries(Object.entries(list).map(([k, f]) => [k, b(f)])) return { big: transform(benchmark), diff --git a/fjs/types/bit_vec/module.f.mjs b/fjs/types/bit_vec/module.f.mjs index 396de7788..6228f5ca1 100644 --- a/fjs/types/bit_vec/module.f.mjs +++ b/fjs/types/bit_vec/module.f.mjs @@ -27,7 +27,7 @@ * @import { Absorbing } from '../../common/monoid/types.ts' * @import { Sign } from '../function/compare/types.ts' * @import { Nullable } from '../nullable/types.ts' - * @import { BitOrder, PopFront, Reduce, Unpacked, Vec, _NormOp, _UnpackConcat, } from './types.ts' + * @import { BitOrder, PopFront, Reduce, Unpacked, Vec, _Base, _NormOp, _UnpackConcat, } from './types.ts' */ import { bitLength, divUp, mask, maxLength, xor } from '../bigint/module.f.mjs' @@ -170,15 +170,6 @@ const op = norm => op => ap => bp => { return vec(len)(op(a)(b)) } -/** - * @typedef {{ - * readonly norm: _NormOp - * readonly uintCmp: (a: bigint) => (b: bigint) => Sign - * readonly unpackSplit: (len: bigint) => (u: Unpacked) => readonly[bigint, bigint] - * readonly unpackConcatUint: (a: Unpacked) => (b: Unpacked) => bigint - * }} _Base - */ - const unpackEmpty = /** @type {const} */{ length: 0n, uint: 0n } /** diff --git a/fjs/types/bit_vec/types.ts b/fjs/types/bit_vec/types.ts index 93b1d7bed..58c940a51 100644 --- a/fjs/types/bit_vec/types.ts +++ b/fjs/types/bit_vec/types.ts @@ -38,6 +38,14 @@ export type _NormOp = Binary export type _UnpackConcat = (a: Unpacked) => (b: Unpacked) => Unpacked +/** The order-specific operations a `BitOrder` is assembled from. */ +export type _Base = { + readonly norm: _NormOp + readonly uintCmp: (a: bigint) => (b: bigint) => Sign + readonly unpackSplit: (len: bigint) => (u: Unpacked) => readonly [bigint, bigint] + readonly unpackConcatUint: (a: Unpacked) => (b: Unpacked) => bigint +} + export type Reduce = OpReduce export type PopFront = (len: bigint) => (u: T) => readonly [bigint, T] diff --git a/fjs/types/btree/remove/module.f.mjs b/fjs/types/btree/remove/module.f.mjs index c93b27602..77703d56e 100644 --- a/fjs/types/btree/remove/module.f.mjs +++ b/fjs/types/btree/remove/module.f.mjs @@ -7,6 +7,7 @@ * @import { Compare } from '../../function/compare/types.ts' * @import { Path, PathItem } from '../find/types.ts' * @import { Tuple } from '../../array/types.ts' + * @import { _Branch, _Leaf01, _Merge, _RemovePath } from './private.ts' */ import { collapseRoot } from '../types/module.f.mjs' @@ -14,19 +15,6 @@ import { find } from '../find/module.f.mjs' import { fold, concat, next } from '../../list/module.f.mjs' import { map } from '../../nullable/module.f.mjs' -/** - * @template T - * @typedef {null | Leaf1} _Leaf01 - */ - -/** - * @template T - * @typedef {{ - * readonly first: _Leaf01, - * readonly tail: Path - * }} _RemovePath - */ - /** @type {(tail: Path) => (n: TNode) => readonly[T, _RemovePath]} */ const path = tail => n => { switch (n.length) { @@ -37,11 +25,6 @@ const path = tail => n => { } } -/** - * @template T - * @typedef {Branch1 | Branch3 | Branch5} _Branch - */ - /** @type {(a: _Branch) => (n: Branch3) => Branch1 | Branch3} */ const reduceValue0 = a => n => { const [, v1, n2] = n @@ -96,12 +79,6 @@ const initValue1 = a => n => { } else { return [n0, v1, a] } } -/** - * @template A - * @template T - * @typedef {(a: A) => (n: Branch3) => Branch1 | Branch3} _Merge - */ - /** @type {(ms: Tuple<2, _Merge>) => (item: PathItem) => (a: A) => _Branch} */ const reduceX = ms => ([i, n]) => a => { /** @typedef {(typeof n)[1]} T */ diff --git a/fjs/types/btree/remove/private.ts b/fjs/types/btree/remove/private.ts new file mode 100644 index 000000000..0f60b6e34 --- /dev/null +++ b/fjs/types/btree/remove/private.ts @@ -0,0 +1,19 @@ +/** + * Implementation-private types for B-tree removal. + * + * @module + */ + +import type { Branch1, Branch3, Branch5, Leaf1 } from '../types/types.ts' +import type { Path } from '../find/types.ts' + +export type _Leaf01 = null | Leaf1 + +export type _RemovePath = { + readonly first: _Leaf01, + readonly tail: Path +} + +export type _Branch = Branch1 | Branch3 | Branch5 + +export type _Merge = (a: A) => (n: Branch3) => Branch1 | Branch3 diff --git a/fjs/types/btree/set/module.f.mjs b/fjs/types/btree/set/module.f.mjs index d65153747..56595b082 100644 --- a/fjs/types/btree/set/module.f.mjs +++ b/fjs/types/btree/set/module.f.mjs @@ -13,15 +13,10 @@ import { find } from '../find/module.f.mjs' import { fold } from '../../list/module.f.mjs' import { assert } from '../../../asserts/module.f.mjs' -/** - * @template T - * @typedef {Branch1 | Branch3} _Branch1To3 - */ - -/** @type {(b: Branch5 | Branch7) => _Branch1To3} */ +/** @type {(b: Branch5 | Branch7) => Branch1 | Branch3} */ const b57 = b => b.length === 5 ? [b] : [[b[0], b[1], b[2]], b[3], [b[4], b[5], b[6]]] -/** @type {(i: PathItem) => (a: _Branch1To3) => _Branch1To3} */ +/** @type {(i: PathItem) => (a: Branch1 | Branch3) => Branch1 | Branch3} */ const reduceOp = ([i, x]) => a => { switch (i) { case 0: { @@ -57,7 +52,7 @@ const nodeSet = c => g => node => { // readonly[1|3, Branch5] /** @type {First} */ const [i, x] = first - /** @type {() => _Branch1To3} */ + /** @type {() => Branch1 | Branch3} */ const f = () => { switch (i) { case 0: { diff --git a/fjs/types/byte_set/module.f.mjs b/fjs/types/byte_set/module.f.mjs index d230c264a..96b0aa0dd 100644 --- a/fjs/types/byte_set/module.f.mjs +++ b/fjs/types/byte_set/module.f.mjs @@ -5,14 +5,12 @@ * @module * * @import { RangeMap } from '../range_map/types.ts' - * @import { ByteSet } from './types.ts' + * @import { ByteSet, _Byte } from './types.ts' */ import { compose } from '../function/module.f.mjs' import { reverse, countdown, flat, map } from '../list/module.f.mjs' -/** @typedef {number} _Byte */ - /** @type {(n: _Byte) => (s: ByteSet) => boolean} */ export const has = n => s => ((s >> BigInt(n)) & 1n) === 1n diff --git a/fjs/types/byte_set/types.ts b/fjs/types/byte_set/types.ts index de4ce510e..b43392007 100644 --- a/fjs/types/byte_set/types.ts +++ b/fjs/types/byte_set/types.ts @@ -5,3 +5,6 @@ */ export type ByteSet = bigint + +/** A member of a `ByteSet`: an unsigned integer below 256. */ +export type _Byte = number diff --git a/fjs/types/function/todo/uncurry-accumulator-types.md b/fjs/types/function/todo/uncurry-accumulator-types.md index 912aa0408..573c36643 100644 --- a/fjs/types/function/todo/uncurry-accumulator-types.md +++ b/fjs/types/function/todo/uncurry-accumulator-types.md @@ -10,13 +10,14 @@ Several sibling accumulator types still curry their data parameters, contradicting that precedent: ```ts -// fjs/types/function/operator/module.f.mjs +// fjs/types/function/operator/types.ts export type Fold = Binary // (input: I) => (acc: O) => O export type Reduce = Fold // (value: T) => (acc: T) => T -// fjs/types/sorted_list/module.f.mjs -/** @typedef {(state: S) => (a: T) => (b: T) => readonly [Nullable, Sign, S]} ReduceOp */ -/** @typedef {(state: S) => (tail: List) => List} TailReduce */ +// fjs/types/sorted_list/types.ts +export type ReduceOp = + (state: S) => (a: T) => (b: T) => readonly [Nullable, Sign, S] +export type TailReduce = (state: S) => (tail: List) => List ``` ### Proposal diff --git a/fjs/types/number/module.f.mjs b/fjs/types/number/module.f.mjs index 650917b05..7dfebe1e3 100644 --- a/fjs/types/number/module.f.mjs +++ b/fjs/types/number/module.f.mjs @@ -32,9 +32,7 @@ export const max = reduce(maxReduce)(null) /** @type {(a: number) => (b: number) => Sign} */ export const cmp = uCmp -/** @typedef {readonly [number, number]} _MaskOffset */ - -/** @type {readonly _MaskOffset[]} */ +/** @type {readonly (readonly [number, number])[]} */ const mo = [ [0x5555_5555, 1], [0x3333_3333, 2], diff --git a/fjs/types/object/proof.f.mjs b/fjs/types/object/proof.f.mjs index 9d8d2cbd4..3f0d04b4f 100644 --- a/fjs/types/object/proof.f.mjs +++ b/fjs/types/object/proof.f.mjs @@ -7,15 +7,13 @@ import { at } from './module.f.mjs' import { assertEq } from '../../asserts/module.f.mjs' -/** @typedef {Assert, { readonly [k in string]?: bigint }>>} _StringMapIsOptional */ - -/** @typedef {Assert, { readonly a?: bigint; readonly b?: bigint }>>} _OptionalIsPartial */ - -/** @typedef {Assert, { readonly a: bigint; readonly b: bigint }>>} _RequiredIsRequired */ - -/** @typedef {Assert, never>>} _RequiredOverAnyStringIsNever */ - export const proof = { + maps: () => { + /** @typedef {Assert, { readonly [k in string]?: bigint }>>} _StringMapIsOptional */ + /** @typedef {Assert, { readonly a?: bigint; readonly b?: bigint }>>} _OptionalIsPartial */ + /** @typedef {Assert, { readonly a: bigint; readonly b: bigint }>>} _RequiredIsRequired */ + /** @typedef {Assert, never>>} _RequiredOverAnyStringIsNever */ + }, ctor: () => { const a = {} const value = at('constructor')(a) diff --git a/fjs/types/patricia_trie/proof.f.mjs b/fjs/types/patricia_trie/proof.f.mjs index e7a44a266..c303f3811 100644 --- a/fjs/types/patricia_trie/proof.f.mjs +++ b/fjs/types/patricia_trie/proof.f.mjs @@ -5,12 +5,10 @@ import { assert, assertEq } from '../../asserts/module.f.mjs' import { emptyState, patriciaTrie } from './module.f.mjs' -/** @typedef {readonly [bigint, bigint, bigint][]} _NodeList */ - /** @type {(a: bigint, b: bigint) => bigint} */ const combine = (a, b) => a * 1_000n + b -/** @type {(a: bigint, b: bigint, s: _NodeList) => readonly [bigint, _NodeList]} */ +/** @type {(a: bigint, b: bigint, s: readonly [bigint, bigint, bigint][]) => readonly [bigint, readonly [bigint, bigint, bigint][]]} */ const create = (a, b, s) => { const h = combine(a, b) return [h, [...s, [a, b, h]]] @@ -18,12 +16,12 @@ const create = (a, b, s) => { const { push, end } = patriciaTrie(create) -/** @type {(state: State<_NodeList, bigint>) => readonly bigint[]} */ +/** @type {(state: State) => readonly bigint[]} */ const leaves = ([, candidates]) => candidates.map(([leaf]) => leaf) /** @type {(inputs: readonly bigint[], expectedLeaves: readonly (readonly bigint[])[], expectedNodeCounts: readonly number[]) => void} */ const runExample = (inputs, expectedLeaves, expectedNodeCounts) => { - /** @type {State<_NodeList, bigint>} */ + /** @type {State} */ let state = emptyState([]) for (let i = 0; i < inputs.length; i++) { const x = inputs[i] diff --git a/fjs/types/range_map/module.f.mjs b/fjs/types/range_map/module.f.mjs index 71515cf4a..80a38ef90 100644 --- a/fjs/types/range_map/module.f.mjs +++ b/fjs/types/range_map/module.f.mjs @@ -47,13 +47,11 @@ import { next } from '../list/module.f.mjs' import { cmp } from '../number/module.f.mjs' import { bsearch } from '../function/compare/module.f.mjs' -/** @template T @typedef {Nullable>} _RangeState */ - const reduceOp = /** * @template T * @param {Properties} p - * @returns {ReduceOp, _RangeState>} + * @returns {ReduceOp, Nullable>>} */ ({ union, equal }) => state => ([aItem, aMax]) => ([bItem, bMax]) => { const sign = cmp(aMax)(bMax) @@ -67,7 +65,7 @@ const tailReduce = /** * @template T * @param {Equal} equal - * @returns {TailReduce, _RangeState>} + * @returns {TailReduce, Nullable>>} */ equal => state => tail => { if (state === null) { return tail } diff --git a/fjs/types/sorted_list/module.f.mjs b/fjs/types/sorted_list/module.f.mjs index 3838149ea..effdadb2b 100644 --- a/fjs/types/sorted_list/module.f.mjs +++ b/fjs/types/sorted_list/module.f.mjs @@ -12,8 +12,6 @@ import { bsearch } from '../function/compare/module.f.mjs' import { next } from '../list/module.f.mjs' import { identity } from '../function/module.f.mjs' -/** @template T @typedef {readonly T[]} _SortedArray */ - /** * Two-way sorted-list merge. * `reduceOp` returns `[output, sign, nextState]` where sign `-1` advances `a`, `1` advances `b`, `0` advances both; `null` output skips emission. @@ -46,8 +44,6 @@ export const genericMerge = return f } -/** @template T @typedef {ReduceOp} _CmpReduceOp */ - export const merge = /** * @template T @@ -60,7 +56,7 @@ const cmpReduce = /** * @template T * @param {Cmp} cmp - * @returns {_CmpReduceOp} + * @returns {ReduceOp} */ cmp => () => a => b => { const sign = cmp(a)(b) @@ -111,7 +107,7 @@ export const find = cmp => /** @param {T} value */ value => - /** @param {_SortedArray} array */ + /** @param {readonly T[]} array */ array => { const cmpValue = cmp(value) const pos = bsearch(array.length)(mid => cmpValue(array[mid])) diff --git a/fjs/web/module.f.mjs b/fjs/web/module.f.mjs index 2e9c28a07..119ed76a4 100644 --- a/fjs/web/module.f.mjs +++ b/fjs/web/module.f.mjs @@ -125,16 +125,6 @@ const percentDecode = s => { return utf8String([...utf8Bytes(literal), ...escaped.flatMap(escapeBytes)]) } -/** - * A request target, split into the two parts that decide the answer. - * - * `authority` is the host the *target* names, which only an absolute-form target - * carries; `null` says the target named none, and the `Host` header is then the - * only thing that does. - * - * @typedef {{ readonly authority: Nullable, readonly path: string }} _Target - */ - /** What separates a scheme from the authority that follows it. * * @type {string} @@ -178,7 +168,7 @@ const portMark = ':' * The fragment is stripped although a client keeps it to itself; a `respond` * called directly might still be given one, and it costs one `split`. * - * @type {(target: string) => Nullable<_Target>} + * @type {(target: string) => Nullable<{ readonly authority: Nullable, readonly path: string }>} */ const parseTarget = target => { const [beforeFragment] = target.split('#') @@ -291,20 +281,16 @@ export const resolve = root => url => { * A file too large to answer with. `readFile` yields a single `Vec`, so this is * a limit of the effect rather than a policy: see the README. * - * @typedef {readonly['tooLarge', number]} _TooLarge + * @type {(size: number) => readonly['tooLarge', number]} */ - -/** @type {(size: number) => _TooLarge} */ const tooLarge = size => ['tooLarge', size] /** * An entry that is not a regular file — a FIFO, a device, a socket. It exists, * so this is not a missing path, and it is not something this server will read. * - * @typedef {readonly['notRegular']} _NotRegular + * @type {readonly['notRegular']} */ - -/** @type {_NotRegular} */ const notRegular = ['notRegular'] /** @@ -468,7 +454,7 @@ const methodNotAllowed = () => { * stall every other response. Size cannot stand in for that check, because a * FIFO stats as zero bytes and passes every bound. * - * @type {(path: string) => (s: FileStat) => Effect} + * @type {(path: string) => (s: FileStat) => Effect} */ const readBounded = path => ({ size, isFile }) => { if (!isFile) { return pureError(notRegular) } @@ -480,7 +466,7 @@ const readBounded = path => ({ size, isFile }) => { * error channel ends: every failure becomes a status code, which is what lets * a `RequestListener` declare `never`. * - * @type {(path: string) => (r: Result) => ServerResponse} + * @type {(path: string) => (r: Result) => ServerResponse} */ const fileResponse = path => r => { if (r[0] === 'ok') { return response(200)(detectPath(path))(r[1]) } diff --git a/fjs/website/browser-prepare.mjs b/fjs/website/browser-prepare.mjs index 1515d18c7..e2e026adb 100644 --- a/fjs/website/browser-prepare.mjs +++ b/fjs/website/browser-prepare.mjs @@ -25,8 +25,6 @@ const files = async directory => { }))).flat() } -/** @typedef {{ readonly blockers: readonly string[], readonly local: readonly URL[] }} _Module */ - /** * Reads the modules reachable from `frontier` one level at a time, recording * for each the bare and `node:` specifiers that would keep a browser from @@ -39,7 +37,10 @@ const files = async directory => { * that were never its own. A genuinely missing relative import cannot survive * anyway, since the proof suite loads every one of these modules in Node. * - * @type {(frontier: readonly URL[], graph: ReadonlyMap) => Promise>} + * @type {( + * frontier: readonly URL[], + * graph: ReadonlyMap, + * ) => Promise>} */ const readGraph = async (frontier, graph) => { const next = frontier.filter(url => !graph.has(url.href)) @@ -60,7 +61,7 @@ const readGraph = async (frontier, graph) => { * The blockers reachable from `root`, deduplicated. Empty means the whole * dependency graph is plain relative ES modules, which a browser can link. * - * @type {(graph: ReadonlyMap, root: URL) => readonly string[]} + * @type {(graph: Awaited>, root: URL) => readonly string[]} */ const blockersOf = (graph, root) => { /** @type {(frontier: readonly string[], visited: ReadonlySet) => ReadonlySet} */ diff --git a/fjs/website/browser-source.mjs b/fjs/website/browser-source.mjs index 3c7b2a871..980f3d3ac 100644 --- a/fjs/website/browser-source.mjs +++ b/fjs/website/browser-source.mjs @@ -25,8 +25,6 @@ const nameChar = char => /** @type {(char: string) => boolean} */ const space = char => char === ' ' || char === '\t' || char === '\n' || char === '\r' -/** @typedef {{ readonly kind: 'name' | 'string' | 'punctuation', readonly text: string }} _Token */ - /** * Separates tokens while they are collected. The scan is a single pass over a * whole file, so tokens accumulate as text rather than into a growing array; @@ -43,7 +41,7 @@ const separator = '\u0000' * An escape inside a string becomes a space: escapes belong to prose, and a * module specifier — the only string this module reads — has none. * - * @type {(source: string) => readonly _Token[]} + * @type {(source: string) => readonly { readonly kind: 'name' | 'string' | 'punctuation', readonly text: string }[]} */ const read = source => { let out = '' @@ -100,7 +98,7 @@ const read = source => { * The tokens as bare words, every string literal standing in as a quote: a * declaration is read by its names, and no string can pass for one. * - * @type {(tokens: readonly _Token[]) => readonly string[]} + * @type {(tokens: ReturnType) => readonly string[]} */ const words = tokens => tokens.map(token => token.kind === 'string' ? '\'' : token.text) diff --git a/todo/blocked/jsdoc-typedef-doc-declaration-emit.md b/todo/blocked/jsdoc-typedef-doc-declaration-emit.md index c141d98d4..9a8e4e782 100644 --- a/todo/blocked/jsdoc-typedef-doc-declaration-emit.md +++ b/todo/blocked/jsdoc-typedef-doc-declaration-emit.md @@ -1,5 +1,10 @@ # JSDoc `@typedef` documentation is dropped by tsgo declaration emit +> Authored `.mjs` no longer carries file-scope `@typedef`s +> ([`../../fjs/todo/separate-private-types.md`](../../fjs/todo/separate-private-types.md)), +> so no authored typedef documentation reaches declaration emit any more; this +> upstream behavior matters again only if that rule is ever relaxed. + **Priority:** P2 **Status:** blocked @@ -208,8 +213,8 @@ Body: - [`todo/migrate-typescript-to-mjs.md`](../migrate-typescript-to-mjs.md) — "Typedef documentation does not survive declaration emit". -- [`jsdoc-typedef-strip-internal.md`](./jsdoc-typedef-strip-internal.md) — - the adjacent `@internal` + `stripInternal` gap for JSDoc typedefs. +- [`../../fjs/todo/separate-private-types.md`](../../fjs/todo/separate-private-types.md) + — private-type placement; superseded the wait-for-`@internal` strategy. - [microsoft/TypeScript#43534](https://github.com/microsoft/TypeScript/issues/43534), [microsoft/TypeScript#61664](https://github.com/microsoft/TypeScript/issues/61664) — adjacent strada behaviors. diff --git a/todo/blocked/jsdoc-typedef-strip-internal.md b/todo/blocked/jsdoc-typedef-strip-internal.md deleted file mode 100644 index fadfd6d1f..000000000 --- a/todo/blocked/jsdoc-typedef-strip-internal.md +++ /dev/null @@ -1,113 +0,0 @@ -# Use `@internal` for private JSDoc typedefs - -**Priority:** P3 -**Status:** blocked - -### Problem - -During the TypeScript-to-JavaScript migration, implementation-only TypeScript -types become JSDoc `@typedef`s. TypeScript currently emits those typedefs as -exported type aliases in generated declarations even when they are not intended -to be public API. - -Until the declaration emitter can strip private JSDoc typedefs, the repository -uses a leading `_` as an API convention: a typedef such as `_Node` is private by -contract even if the generated `.d.ts` / `.d.mts` contains `export type _Node`. -Consumers must not depend on that emitted name directly, so renaming or removing -the alias is not a breaking change solely because it was emitted. This does not -exempt changes propagated into public types: if a public declaration depends on -`_Node`, any change that alters that public declaration's assignability remains -a breaking API change. - -The desired long-term representation is `@internal` plus `stripInternal`, so the -generated declaration does not expose the private type at all. - -### Trigger - -Unblocked when the TypeScript compiler used by this repository supports applying -`@internal` to JSDoc `@typedef` declarations and `stripInternal` reliably omits -those typedefs from generated `.d.ts` / `.d.mts` files. - -The canonical blocker is -[microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407), -which is still open and specifically requests `stripInternal` support for types -defined with JSDoc. - -Another open TypeScript declaration/comment-emission issue, -[microsoft/TypeScript#62453](https://github.com/microsoft/TypeScript/issues/62453), -demonstrates the same JSDoc typedef-to-`export type` emission path while tracking -duplicated typedef comments. It is related context, not the visibility blocker. - -A separate equivalent TypeScript 7 / Go issue was not found in -`microsoft/typescript-go`. The native compiler does implement `stripInternal` -in general, but the known JSDoc declaration-emission reports still show -`@typedef`s becoming exported aliases. Related TypeScript-Go issues are: - -- [microsoft/typescript-go#4363](https://github.com/microsoft/typescript-go/issues/4363) - — open; emitted JSDoc typedef aliases and their documentation ordering. -- [microsoft/typescript-go#4235](https://github.com/microsoft/typescript-go/issues/4235) - — closed; JSDoc typedef/property documentation in declaration emit. -- [microsoft/typescript-go#4011](https://github.com/microsoft/typescript-go/issues/4011) - — closed; correctness of generated declaration syntax for JSDoc typedefs. - -These TypeScript-Go issues are adjacent declaration-emitter bugs, not substitutes -for #46407. Re-check the current TypeScript tracker when this task is unblocked, -especially as the native compiler work is consolidated with the main TypeScript -project. - -### Proposal - -Once the trigger is satisfied: - -1. enable or retain `stripInternal` for declaration emission; -2. mark implementation-only JSDoc typedefs with `@internal`; -3. remove leading `_` from private typedef names where the prefix exists only as - the current visibility workaround; -4. add a package/declaration fixture proving that private typedefs are absent - from emitted declarations while public declarations remain valid; -5. update migration, compiler, package, and contributor documentation to remove - the underscore workaround. - -Do not strip a private typedef if a public declaration still depends on its name; -refactor the public declaration first so emitted declarations remain -self-contained and preserve the same public assignability contract. - -### Tasks - -- [ ] Enable or retain `stripInternal` for declaration emission once the trigger - is satisfied. -- [ ] Mark implementation-only JSDoc typedefs with `@internal`. -- [ ] Remove leading `_` from private typedef names where the prefix exists only - as the temporary visibility workaround. -- [ ] Refactor public declarations that refer to private typedef names so they - remain self-contained and preserve the same public assignability contract - before those private typedefs are stripped. -- [ ] Add a package/declaration fixture proving that private typedefs are absent - from emitted declarations while public declarations remain valid. -- [ ] Update migration, compiler, package, and contributor documentation to - remove the underscore workaround. - -### Acceptance criteria - -- `@internal` on a JSDoc `@typedef` is honored by the repository's TypeScript - declaration emitter when `stripInternal` is enabled. -- Generated `.d.ts` / `.d.mts` files omit implementation-only typedefs. -- Public emitted declarations never reference a stripped private type. -- Removing the `_` workaround does not weaken or otherwise change public - assignability unless that change is explicitly treated as breaking. -- The `_`-prefix workaround is removed from repository documentation and from - private typedefs that used it solely for visibility. -- Clean package-consumer type checking still passes. - -### Related - -- [`../migrate-typescript-to-mjs.md`](../migrate-typescript-to-mjs.md) — Stage 1 - TypeScript-to-JSDoc migration and the temporary `_` convention. -- [`../../fjs/fsc/README.md`](../../fjs/fsc/README.md) — source migration and - JSDoc visibility contract. -- [`../../fjs/ci/todo/f-mjs-package-support.md`](../../fjs/ci/todo/f-mjs-package-support.md) - — declaration-emission and clean-consumer validation. -- [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) - — canonical upstream feature request. -- [microsoft/TypeScript#62453](https://github.com/microsoft/TypeScript/issues/62453) - — related JSDoc typedef declaration/comment emission bug. diff --git a/todo/migrate-typescript-to-mjs.md b/todo/migrate-typescript-to-mjs.md index 0065dce91..6d29eb8bf 100644 --- a/todo/migrate-typescript-to-mjs.md +++ b/todo/migrate-typescript-to-mjs.md @@ -340,45 +340,41 @@ consumer all work; that is tracked in #### Preserve private type intent with `_` -A non-exported TypeScript type that is translated into a JavaScript `@typedef` -can become externally visible merely because TypeScript currently emits JSDoc -typedefs as exported aliases. The upstream request to make `@internal` plus -`stripInternal` work for JSDoc typedefs is -[microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407). - -Until that support is available, prefix implementation-only **JSDoc typedef** -names with `_` during migration. For example: +A named type migrating out of a `.f.ts` never becomes a **file-scope** JSDoc +`@typedef` — authored `.mjs` files carry none, repository-wide (root +`AGENTS.md`; design in +[`../fjs/todo/separate-private-types.md`](../fjs/todo/separate-private-types.md)). +It lands in the sibling `types.ts` when it is part of the public declaration +closure, in an optional sibling `private.ts` when it is implementation-private +and separating it reads cleaner than inlining, inline in the annotations that +use it, or function-local in a proof when it is a compile-time proof type. For +example: ```ts type Node = number export type Tree = readonly Node[] ``` -becomes conceptually: +becomes, in `types.ts`: -```js -/** @typedef {number} _Node */ -/** @typedef {readonly _Node[]} Tree */ +```ts +export type _Node = number +export type Tree = readonly _Node[] ``` -The leading `_` is the FunctionalScript API visibility convention. It does not -prevent declaration emission, so generated declarations may contain -`export type _Node = number`. `_Node` is still private by contract: consumers -must not depend on that emitted name directly, so renaming or removing `_Node` -is not a breaking change solely because TypeScript exposed the alias. +The leading `_` is the FunctionalScript API visibility convention, kept even +when linkage requires an export: `_Node` is private by contract, so consumers +must not depend on the name directly, and renaming or removing `_Node` is not a +breaking change solely because a declaration exposed it. The public contract still governs transitive effects. In the example above, `Tree` is public and depends on `_Node`; changing `_Node` from `number` to `string` changes `Tree`'s public assignability and is therefore a breaking change. The underscore exempts only the private alias itself, never a change to -the expanded public API. Public typedefs keep ordinary names without a leading +the expanded public API. Public types keep ordinary names without a leading `_`. -Types intentionally separated into `types.ts` use ordinary TypeScript source -visibility and syntax and do not need the JSDoc underscore workaround merely -because they remain TypeScript. - -Which JSDoc typedefs are public is an API design decision made at the migration +Which types are public is an API design decision made at the migration boundary, not a mechanical copy of what the `.f.ts` happened to export. The `.f.ts` -> `.f.mjs` rename is already a breaking change — importers must update the specifier — so it is the one moment where a module's JSDoc visibility @@ -402,12 +398,19 @@ plans to remove both. Hiding a type behind `_` to make its eventual removal cheaper gives up a real present-day API in exchange for a discount on a breaking change that should simply be documented when it happens. -This convention is temporary. Once TypeScript can strip `@internal` JSDoc -typedefs correctly, replace the underscore workaround as tracked by -[`blocked/jsdoc-typedef-strip-internal.md`](./blocked/jsdoc-typedef-strip-internal.md). +Unshipping generated private declaration artifacts is the packaging stage of +[`../fjs/todo/separate-private-types.md`](../fjs/todo/separate-private-types.md); +the `_` contract itself is permanent. #### Typedef documentation does not survive declaration emit +> Since the repository-wide prohibition on file-scope `@typedef` in authored +> `.mjs` ([`../fjs/todo/separate-private-types.md`](../fjs/todo/separate-private-types.md)), +> named types live in `types.ts`/`private.ts`, whose documentation emits +> through the normal TypeScript pipeline — so this loss no longer affects +> authored code. The record below explains the behavior and why the +> prohibition avoids it. + The same upstream gap has a second, opposite-facing symptom: declaration emit drops the documentation written on a JSDoc `@typedef`. A TypeScript `/** 8-word SHA-2 state vector. */ export type V8 = …` keeps its comment in the @@ -866,12 +869,13 @@ blocking, plus the prose sweep. The remaining items are listed under emitted declarations measure zero `elided` repo-wide after it. (Its Phantom `$out` intentionally differs from `Ts` in field optionality, so no exact `Equal` round-trip assert applies there.) -- [ ] Decide each JSDoc typedef's visibility at the migration boundary: prefix - implementation-only typedefs with `_` and leave publicly useful ones +- [ ] Decide each migrated type's visibility at the migration boundary: prefix + implementation-only types with `_` and leave publicly useful ones unprefixed, judged by what the module should offer its consumers rather than by what the `.f.ts` happened to export or by what a pending refactor - plans to delete. Types intentionally moved to `types.ts` use normal - TypeScript source visibility instead. + plans to delete. Place each per the file-scope-typedef prohibition: + `types.ts` for the public declaration closure, optional `private.ts`, + inline, or function-local in a proof. - [x] Apply the module-header/import convention: `@module` belongs only to `module.*` entry-point files, never to `proof.*` or other files; group module-level JavaScript `@import` tags into one leading JSDoc block — @@ -1250,9 +1254,9 @@ person can re-check rather than re-derive. Counts are as of — broader package-publishing plan. - [`../fjs/fsc/README.md`](../fjs/fsc/README.md) — authoritative FunctionalScript extension and migration contract. -- [`blocked/jsdoc-typedef-strip-internal.md`](./blocked/jsdoc-typedef-strip-internal.md) - — replace the temporary `_` convention with `@internal` when upstream - declaration emit supports it. +- [`../fjs/todo/separate-private-types.md`](../fjs/todo/separate-private-types.md) + — private-type placement rules and the packaging stage that unships + generated private declarations. - [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) — upstream request for `stripInternal` support on JSDoc typedefs. - [`fjs-nanvm-integration.md`](./fjs-nanvm-integration.md) — existing compiler diff --git a/todo/proof.f.mjs b/todo/proof.f.mjs index 311694345..b4c75b5a7 100644 --- a/todo/proof.f.mjs +++ b/todo/proof.f.mjs @@ -1,5 +1,3 @@ -/** @typedef {`${string}`} _TemplateType */ - /** @type {(a: unknown) => (i: any) => unknown} */ const at = a => i => Object.getOwnPropertyDescriptor(a, i)?.value @@ -8,6 +6,7 @@ const utf8 = (...x) => x export const proof = { literal: () => { + /** @typedef {`${string}`} _TemplateType */ const x = utf8`17` /** @type {_TemplateType} */ const m = 'Hello' From 16ec8c1f95c58bef63e8bc2e4cafd6dee6585d1b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:43:32 +0000 Subject: [PATCH 137/370] changelog: entry for the private-type moves Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- changelog/unreleased/1750.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog/unreleased/1750.md diff --git a/changelog/unreleased/1750.md b/changelog/unreleased/1750.md new file mode 100644 index 000000000..a66ef9e91 --- /dev/null +++ b/changelog/unreleased/1750.md @@ -0,0 +1,5 @@ +- **BREAKING CHANGES:** named types moved out of authored `.mjs`: `Grammar` now + imports from `fjs/fsm/types.ts`; `MemoryOperationMap`, `MemoryRun`, and + `Uuid` from `fjs/effects/node/memory/types.ts`; the JSON-Schema `Unknown` + alias is gone — spell it `Ts`. Private `_` types now live in + `types.ts` or `private.ts`, never as file-scope typedefs in `.mjs`. From 73318222f4a365550a6c5581b79c2bc1d39e2b92 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:45:48 +0000 Subject: [PATCH 138/370] fjs: type-only @import for values used only in proof typedefs Values referenced only through typeof in consistency-proof typedefs do not need a runtime import; the JSDoc @import serves the type position and the code-quality unused-import finding goes away. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/edag/proof.f.mjs | 29 ++++++++++++++++++++++++----- fjs/media/json/schema/proof.f.mjs | 3 ++- fjs/media/revision/proof.f.mjs | 3 ++- fjs/rtti/proof.f.mjs | 3 ++- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/fjs/edag/proof.f.mjs b/fjs/edag/proof.f.mjs index 39294126e..b41b23e50 100644 --- a/fjs/edag/proof.f.mjs +++ b/fjs/edag/proof.f.mjs @@ -15,6 +15,28 @@ * @import { Check, Check3, Unknown } from '../rtti/ts/types.ts' * @import { StringMap } from '../types/object/types.ts' * @import { + * _exp, + * _optionLambda, + * _optionPropertyLambda, + * array, + * call, + * comma, + * dot, + * exps, + * items, + * numberCast, + * object, + * op0, + * op1, + * op2, + * optionCall, + * optionDot, + * primitive, + * properties, + * property, + * spread, + * } from './module.f.mjs' + * @import { * Array, * Call, * Comma, @@ -45,11 +67,8 @@ import { validate } from '../rtti/validate/module.f.mjs' import { assert, assertEq, assertStructurallySame, todo } from '../asserts/module.f.mjs' import { - _exp, _optionLambda, _optionPropertyLambda, - array, call, comma, dot, exp, exps, items, numberCast, object, - op0, op0Id, op1, op1Id, op2, op2Id, - optionCall, optionDot, optionLambda, optionPropertyLambda, - primitive, properties, property, propertyLambda, spread, + exp, op0Id, op1Id, op2Id, + optionLambda, optionPropertyLambda, propertyLambda, } from './module.f.mjs' /** @type {(r: readonly [string, unknown]) => void} */ diff --git a/fjs/media/json/schema/proof.f.mjs b/fjs/media/json/schema/proof.f.mjs index fa897022b..b5b1ea427 100644 --- a/fjs/media/json/schema/proof.f.mjs +++ b/fjs/media/json/schema/proof.f.mjs @@ -1,12 +1,13 @@ /** * @import { Ts, Check } from '../../../rtti/ts/types.ts' * @import { Assert } from '../../../asserts/types.ts' + * @import { _unknownThunk } from './module.f.mjs' * @import { Data } from '../../../rtti/data/types.ts' */ import { boolean, number, string, bigint, never, unknown, array, open, record, or, option } from '../../../rtti/module.f.mjs' import { stringify } from '../module.f.mjs' -import { _unknownThunk, dataToJsonSchema, toJsonSchema, unknown as schemaUnknown } from './module.f.mjs' +import { dataToJsonSchema, toJsonSchema, unknown as schemaUnknown } from './module.f.mjs' import { unitBit } from '../../../rtti/data/module.f.mjs' import { assert, assertEq } from '../../../asserts/module.f.mjs' diff --git a/fjs/media/revision/proof.f.mjs b/fjs/media/revision/proof.f.mjs index dc7a7f9b3..e8f60fec2 100644 --- a/fjs/media/revision/proof.f.mjs +++ b/fjs/media/revision/proof.f.mjs @@ -3,10 +3,11 @@ * @import { Object as JsonObject } from '../json/types.ts' * @import { Check } from '../../rtti/ts/types.ts' * @import { LockField, LockMap } from './types.ts' + * @import { lock, lockField } from './module.f.mjs' */ import { assert, assertEq } from '../../asserts/module.f.mjs' -import { dialect, lock, lockField, mediaType, isHash, validate, decodeText, encodeText } from './module.f.mjs' +import { dialect, mediaType, isHash, validate, decodeText, encodeText } from './module.f.mjs' // Valid cbase32 hashes (round-tripped in fjs/basen/cbase32/proof.f.mjs): single // cbase32 symbols, cheap to write inline here. diff --git a/fjs/rtti/proof.f.mjs b/fjs/rtti/proof.f.mjs index 8f65c0498..c1e9eda79 100644 --- a/fjs/rtti/proof.f.mjs +++ b/fjs/rtti/proof.f.mjs @@ -3,10 +3,11 @@ * @import { Assert } from '../asserts/types.ts' * @import { Equal } from '../types/ts/types.ts' * @import { Or, Primitive0, Rest, Tag1, Type1, Unknown } from './types.ts' + * @import { _primitive0List, _tag1List } from './module.f.mjs' */ import { assertNotNullish, assertStructurallySame } from '../asserts/module.f.mjs' -import { _primitive0List, _tag1List, array, number, open, option, or, record, rest, string, unknown } from './module.f.mjs' +import { array, number, open, option, or, record, rest, string, unknown } from './module.f.mjs' /** @type {StringMap} */ const tests = { From eebc0078a01eb48061f5f70eddf8479614af608d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:48:25 +0000 Subject: [PATCH 139/370] fjs: no @module tag on non-entry-point type files fjs/AGENTS.md section 2 reserves @module for module.f.mjs / module.mjs entry points; strip it from the type files this branch added. The pre-existing types.ts files that carry the tag are recorded as debt in fjs/todo/module-tag-on-types-ts.md rather than widened into this change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/asn.1/private.ts | 2 -- fjs/bnf/data/private.ts | 2 -- fjs/bnf/descent/private.ts | 2 -- fjs/bnf/ll1/private.ts | 2 -- fjs/bnf/private.ts | 2 -- fjs/common/monoid/private.ts | 2 -- fjs/djs/ast/private.ts | 2 -- fjs/djs/parser/private.ts | 2 -- fjs/djs/serializer/private.ts | 2 -- fjs/djs/serializer/types.ts | 2 -- fjs/djs/tokenizer/private.ts | 2 -- fjs/effects/node/memory/types.ts | 2 -- fjs/effects/node/private.ts | 2 -- fjs/fsc/types.ts | 2 -- fjs/fsm/types.ts | 2 -- fjs/rtti/data/private.ts | 2 -- fjs/rtti/ts/private.ts | 2 -- fjs/todo/module-tag-on-types-ts.md | 27 +++++++++++++++++++++++++++ fjs/types/bigfloat/private.ts | 2 -- fjs/types/btree/remove/private.ts | 2 -- 20 files changed, 27 insertions(+), 38 deletions(-) create mode 100644 fjs/todo/module-tag-on-types-ts.md diff --git a/fjs/asn.1/private.ts b/fjs/asn.1/private.ts index 2b15d542d..fd64e8dda 100644 --- a/fjs/asn.1/private.ts +++ b/fjs/asn.1/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for ASN.1 tag encoding. - * - * @module */ /** The top three bits of a tag's first byte: class and constructed flag. */ diff --git a/fjs/bnf/data/private.ts b/fjs/bnf/data/private.ts index 232838eef..144a29e11 100644 --- a/fjs/bnf/data/private.ts +++ b/fjs/bnf/data/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for the `toData` conversion. - * - * @module */ import type { Rule as FRule } from '../types.ts' diff --git a/fjs/bnf/descent/private.ts b/fjs/bnf/descent/private.ts index b200ed5b0..9d4e0c34d 100644 --- a/fjs/bnf/descent/private.ts +++ b/fjs/bnf/descent/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for the recursive descent matcher backend. - * - * @module */ import type { TerminalRange } from '../types.ts' diff --git a/fjs/bnf/ll1/private.ts b/fjs/bnf/ll1/private.ts index 55e2d0930..d8442edba 100644 --- a/fjs/bnf/ll1/private.ts +++ b/fjs/bnf/ll1/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for the LL(1) matcher machine. - * - * @module */ import type { CodePoint } from '../../text/utf16/types.ts' diff --git a/fjs/bnf/private.ts b/fjs/bnf/private.ts index 8b047e760..1a0246564 100644 --- a/fjs/bnf/private.ts +++ b/fjs/bnf/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for the AST renderer in `./testlib.f.mjs`. - * - * @module */ import type { Ast } from './matcher/types.ts' diff --git a/fjs/common/monoid/private.ts b/fjs/common/monoid/private.ts index ffbc1adf9..a86d8a890 100644 --- a/fjs/common/monoid/private.ts +++ b/fjs/common/monoid/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for the monoid fold. - * - * @module */ /** diff --git a/fjs/djs/ast/private.ts b/fjs/djs/ast/private.ts index 68b28227a..6c5e7ce49 100644 --- a/fjs/djs/ast/private.ts +++ b/fjs/djs/ast/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for the DJS AST evaluator. - * - * @module */ import type { List } from '../../types/list/types.ts' diff --git a/fjs/djs/parser/private.ts b/fjs/djs/parser/private.ts index 7649cb761..567963e37 100644 --- a/fjs/djs/parser/private.ts +++ b/fjs/djs/parser/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for the DJS parser. - * - * @module */ import type { CodePointMeta } from '../../bnf/descent/types.ts' diff --git a/fjs/djs/serializer/private.ts b/fjs/djs/serializer/private.ts index a461610dd..ee8cfb80e 100644 --- a/fjs/djs/serializer/private.ts +++ b/fjs/djs/serializer/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for the DJS serializer. - * - * @module */ import type { List } from '../../types/list/types.ts' diff --git a/fjs/djs/serializer/types.ts b/fjs/djs/serializer/types.ts index d58e2bb0c..4ee485353 100644 --- a/fjs/djs/serializer/types.ts +++ b/fjs/djs/serializer/types.ts @@ -1,8 +1,6 @@ /** * Type-level API for `fjs/djs/serializer/module.f.mjs`: the reference-count * map `countRefs` produces and `stringify` hoists `const`s from. - * - * @module */ import type { Unknown } from '../types.ts' diff --git a/fjs/djs/tokenizer/private.ts b/fjs/djs/tokenizer/private.ts index b836d4878..b2d184327 100644 --- a/fjs/djs/tokenizer/private.ts +++ b/fjs/djs/tokenizer/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for the DJS tokenizer. - * - * @module */ import type { CodePointMeta } from '../../bnf/descent/types.ts' diff --git a/fjs/effects/node/memory/types.ts b/fjs/effects/node/memory/types.ts index 2b41d673d..f58f1f21d 100644 --- a/fjs/effects/node/memory/types.ts +++ b/fjs/effects/node/memory/types.ts @@ -1,7 +1,5 @@ /** * Types for the Node.js memory-effect interpreter. - * - * @module */ import type { Effect, ToAsyncOperationMap } from '../../types.ts' diff --git a/fjs/effects/node/private.ts b/fjs/effects/node/private.ts index d3d942a74..bc88d25af 100644 --- a/fjs/effects/node/private.ts +++ b/fjs/effects/node/private.ts @@ -2,8 +2,6 @@ * Implementation-private types for the Node.js effect runner: the narrowed * structural views of `node:http` objects the runner interprets HTTP * operations against. - * - * @module */ import type { StringMap } from '../../types/object/types.ts' diff --git a/fjs/fsc/types.ts b/fjs/fsc/types.ts index 3c2dccdfe..052f96006 100644 --- a/fjs/fsc/types.ts +++ b/fjs/fsc/types.ts @@ -1,7 +1,5 @@ /** * Types for the FunctionalScript compile-workflow state machine. - * - * @module */ import type { RangeMapArray } from '../types/range_map/types.ts' diff --git a/fjs/fsm/types.ts b/fjs/fsm/types.ts index fab6e2e45..d8425f832 100644 --- a/fjs/fsm/types.ts +++ b/fjs/fsm/types.ts @@ -1,7 +1,5 @@ /** * Types for the finite-state-machine grammar and its compiled DFA. - * - * @module */ import type { List } from '../types/list/types.ts' diff --git a/fjs/rtti/data/private.ts b/fjs/rtti/data/private.ts index 0233224fa..641509001 100644 --- a/fjs/rtti/data/private.ts +++ b/fjs/rtti/data/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for the RTTI data conversion. - * - * @module */ import type { StringMap } from '../../types/object/types.ts' diff --git a/fjs/rtti/ts/private.ts b/fjs/rtti/ts/private.ts index e3891a577..64c9f4305 100644 --- a/fjs/rtti/ts/private.ts +++ b/fjs/rtti/ts/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for the RTTI-to-TypeScript printer. - * - * @module */ import type { Printer } from '../../types/ts/types.ts' diff --git a/fjs/todo/module-tag-on-types-ts.md b/fjs/todo/module-tag-on-types-ts.md new file mode 100644 index 000000000..f1ebf7079 --- /dev/null +++ b/fjs/todo/module-tag-on-types-ts.md @@ -0,0 +1,27 @@ +## `@module` on existing `types.ts` files contradicts the header convention + +**Priority:** P3 +**Status:** open + +### Problem + +`fjs/AGENTS.md` §2 reserves the `@module` tag for a package's entry-point file +(`module.f.mjs` / `module.mjs`) and explicitly excludes `types.ts`. Yet the +pre-existing `types.ts` files across the repository — all 23 under +`fjs/types/*/types.ts`, plus others such as `fjs/effects/types.ts` — carry +`@module` in their header block. + +New helper type files (`types.ts` / `private.ts`) added since the private-type +migration follow the documented rule and carry no `@module`; the older files +were left as found so that the migration did not widen. + +### Tasks + +- [ ] Decide which side is right: strip `@module` from the existing non-entry + `.ts` files, or narrow `fjs/AGENTS.md` §2 if `types.ts` files are meant + to be documented entry points. +- [ ] Apply the decision consistently across the repository. + +### Related + +- [`../AGENTS.md`](../AGENTS.md) — §2 module-header convention. diff --git a/fjs/types/bigfloat/private.ts b/fjs/types/bigfloat/private.ts index 41fa94f95..d0dd82c1d 100644 --- a/fjs/types/bigfloat/private.ts +++ b/fjs/types/bigfloat/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for the big-float module. - * - * @module */ import type { BigFloat } from './types.ts' diff --git a/fjs/types/btree/remove/private.ts b/fjs/types/btree/remove/private.ts index 0f60b6e34..1c1d8152f 100644 --- a/fjs/types/btree/remove/private.ts +++ b/fjs/types/btree/remove/private.ts @@ -1,7 +1,5 @@ /** * Implementation-private types for B-tree removal. - * - * @module */ import type { Branch1, Branch3, Branch5, Leaf1 } from '../types/types.ts' From 141b6204fccacb168f46c873e79805348df4e8d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:52:50 +0000 Subject: [PATCH 140/370] changelog: BrowserTestReport moved too Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- changelog/unreleased/1750.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/changelog/unreleased/1750.md b/changelog/unreleased/1750.md index a66ef9e91..a8fa95a75 100644 --- a/changelog/unreleased/1750.md +++ b/changelog/unreleased/1750.md @@ -1,5 +1,6 @@ - **BREAKING CHANGES:** named types moved out of authored `.mjs`: `Grammar` now imports from `fjs/fsm/types.ts`; `MemoryOperationMap`, `MemoryRun`, and - `Uuid` from `fjs/effects/node/memory/types.ts`; the JSON-Schema `Unknown` - alias is gone — spell it `Ts`. Private `_` types now live in - `types.ts` or `private.ts`, never as file-scope typedefs in `.mjs`. + `Uuid` from `fjs/effects/node/memory/types.ts`; `BrowserTestReport` from + `fjs/emergent_testing/types.ts`; the JSON-Schema `Unknown` alias is gone — + spell it `Ts`. Private `_` types now live in `types.ts` or + `private.ts`, never as file-scope typedefs in `.mjs`. From 465499e520d3c6cca997348a946f57af93b16fbe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:59:51 +0000 Subject: [PATCH 141/370] changelog: hold #1748's entry to the length guideline; todo: the `declared` scan is quadratic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry is rewritten as four short list items — what changed and why, at release-note altitude — so the pull request body's `Changelog:` section can hold exactly the same items, as CONTRIBUTING.md requires. The rationale, measurements and migration detail stay in the pull request description and the module docs, where the changelog README sends them. Review of #1748 also measured a superlinear end-to-end parse at 100k+ tuple positions on both heads. Root-caused to `undeclaredMembers`' per-member `declared.some` linear scan — quadratic on dense tuples, predating this branch — and recorded as `fjs/rtti/todo/undeclared-members-declared-scan.md` with measurements and the O(1)-membership fix sketch, rather than widening this pull request. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/1748.md | 34 ++++++++---------- .../todo/undeclared-members-declared-scan.md | 36 +++++++++++++++++++ 2 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 fjs/rtti/todo/undeclared-members-declared-scan.md diff --git a/changelog/unreleased/1748.md b/changelog/unreleased/1748.md index fc8b0dc8e..8f9c904c7 100644 --- a/changelog/unreleased/1748.md +++ b/changelog/unreleased/1748.md @@ -1,20 +1,14 @@ -- **BREAKING CHANGES:** rtti's `option` is a nullary schema denoting - **absence** — the member that is not there — so a member that may be - omitted is `or(option, t)` and absence stops being a spelling of - `undefined`: `{}` and `{ a: undefined }` are now distinct sets, told apart - by every reader and by `subset`. `option(t)` becomes `or(option, t)`, which - also **narrows**: a schema that accepted a present `undefined` at that - member no longer does — the faithful translation of the old set is - `or(option, t, undefined)`, and every migrated schema in this repository - took the narrowing deliberately. `parse` no longer materializes an absent - member: the struct kind drops the key, the array kind keeps a hole a hole - and shortens a trailing absent run, so an optional member survives a JSON - round-trip. In the data form absence is a fifth `unit` bit (`absentBit`), - excluded from `unknown`; a declared `unknown` member is therefore required - now, and "anything, or nothing" is `or(option, unknown)`. `Ts<>` and the - runtime printer render an omittable member optional with absence stripped - (`readonly a?: number`, `readonly [1, number?]`) — exact under - `exactOptionalPropertyTypes` — and `toJsonSchema` derives `required` and - `minItems` from the absent bit. A `Phantom` annotation on a schema whose - root admits absence must carry the new `Absent` marker, pinned with the - new `CheckRaw`. +- **BREAKING CHANGES:** `rtti`: `option` is a nullary schema denoting + **absence** — a member that may be omitted is `or(option, t)`, and `{}` is + no longer the same set as `{ a: undefined }`. The old `option(t)` set is + `or(option, t, undefined)`; plain `or(option, t)` rejects a present + `undefined`. +- `rtti`: `parse` omits an absent member — the struct kind drops the key, + the array kind keeps holes and shortens a trailing absent run — so an + optional member survives a JSON round-trip. +- `rtti`: `unknown` excludes absence, so a member declared `unknown` is + required and the omittable top is `or(option, unknown)`. `Ts<>`, the + runtime printer and `toJsonSchema` derive optionality (`?`, `required`, + `minItems`) from absence — exact under `exactOptionalPropertyTypes`. +- `rtti`: a `Phantom` annotation on a schema whose root admits absence + carries the new `Absent` marker, pinned with the new `CheckRaw`. diff --git a/fjs/rtti/todo/undeclared-members-declared-scan.md b/fjs/rtti/todo/undeclared-members-declared-scan.md new file mode 100644 index 000000000..c78c3b08e --- /dev/null +++ b/fjs/rtti/todo/undeclared-members-declared-scan.md @@ -0,0 +1,36 @@ +# `undeclaredMembers` scans `declared` linearly per member + +**Priority:** P3 +**Status:** open + +## Problem + +`undeclaredMembers` in [`../common/module.f.mjs`](../common/module.f.mjs) +answers "is `k` declared?" with `declared.some(d => d === k)` — a linear scan +per member of the value. A tuple's own index `i` sits at position `i` of its +`declared` list, so a dense `n`-position tuple pays `Σ i ≈ n²/2` string +comparisons per read, and the whole walk is quadratic while everything around +it is linear. Measured (`undeclaredMembers` alone, dense all-present tuple): +0.7 s at 25 000 positions, 3.3 s at 50 000 — 4× per doubling. Review of #1748 +measured the same shape end to end: ~17–31 s at 100 000 positions, ~48 s at +200 000, identical on the parent commit, so this predates stage 2 of +`option`-as-omission and none of the readers' recent changes moved it. + +Every reader pays it — `parse`, `validate` and the data form's `validate` all +route undeclared members through this one walk (which is the point of the +shared rule; see the function's own doc) — but only the *array* kind at +scale: a struct's `declared` list is its key list, rarely large. + +## Tasks + +- [ ] Answer membership in O(1): build the membership test once from + `declared` (`new Set(declared)` is the §3.1-sanctioned construction) + — or better, once per **schema** rather than per call, since every + caller already hoists `declared` from `rttiEntries` in a per-schema + closure and the data reader can derive it from `p.prefix`/`p.props` + the same way. +- [ ] Keep the observable behavior bit-identical: `undeclaredMembers`' member + *order* and the non-index/beyond-`length` rules are pinned by + `../host.proof.mjs` and the three-reader tables, and must not move. +- [ ] Pin the complexity the way `readIndices`' doc pins its own linear walk: + a measurement in the JSDoc, not a timing assert in a proof. From 6f80ac990b37c0f179917ed9ccb665463959db58 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:03:01 +0000 Subject: [PATCH 142/370] =?UTF-8?q?todo:=20fjs=20t=20cannot=20falsify=20it?= =?UTF-8?q?s=20own=20reporter=20=E2=80=94=20the=20register=20path=20can?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review measured two surviving mutants: a testResult forced to `passed` and a fold that never counts a failure both leave `fjs t` at exit 0, because the proofs pinning both functions are reported through the functions they test. The note now says that plainly, with the measured counterpart: the external-framework registration path consults neither function, and both mutants fail there under node --test (16 and 18 failures, exit 1), which CI runs on node, bun and deno. Also names the page's pre-existing batchSize=25 as a step-7 decision, not an inheritance. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/share-browser-console-runner.md | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 231b56a05..0bb159d66 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -149,7 +149,11 @@ and is reviewable without the next one. only means "how long the run took" for a sequential runner — `RunTotals` documents that. - [ ] **7. One skeleton.** The page's proof-tree walk is deleted and the shared - traversal runs it. + traversal runs it. The walk's `batchSize = 25` batching goes onto the + table with it: that is a scheduling policy of the page's own — the same + kind the reverted attempt was faulted for inventing, though this one + predates it in `browser.mjs` — and step 7 is where it gets decided + rather than silently inherited. - [ ] **8. The layout move**, and the website preparation program. Steps 3 and 7 are the ones that change behaviour, so they are the ones to keep @@ -172,16 +176,25 @@ alone on purpose: step 2 shares; they disagree on the message, which belongs with the point above. -Note also that `testResult` now sits inside `fjs t`'s own reporting path, so a -defect in it can mislabel the very failures it causes — a mutation forcing every -status to `passed` prints `ok` on failing lines. Since step 6, the pass/fail -counts and the exit code read the same shared status (the walk folds each -leaf's `TestResult` with `addResult`), so such a defect no longer leaves an -honest summary behind either — that duplicate decision was exactly the drift -this issue exists to remove, and what holds the line now is that `testResult` -and `addResult` are pinned by direct proofs rather than by a second -implementation agreeing. Worth remembering when reading output while changing -either function. +Note also that `testResult` and `addResult` now sit inside `fjs t`'s own +reporting path: since step 6 the result lines, the summary counts and the exit +code all read them, so a defect there can mislabel or miscount the very +failures it causes — and **`fjs t` alone cannot see that**. The direct proofs +that pin both functions are themselves reported through the functions they +test: mutate `testResult` to answer `passed` for everything and the proof that +asserts `failed` does fail, but its failure is relabelled `ok` on the way out — +measured, the mutated suite prints 3480 pass, exit 0. Mutate the fold to never +count a failure and the gate (`failed !== 0`) reads the fold it is gating — +exit 0 again, with the total quietly short. That is not a duplicate-decision +problem to fix with a second count (the second count is what step 6 removed); +it is a runner auditing itself, which no arrangement of its own proofs escapes. +What actually holds the line is the *other* execution path: `all.test.mjs` +registers every proof with an external framework (`register`, which consults +neither `testResult` nor `addResult` — a deliberate independence, worth +keeping), and CI runs it under node, bun and deno. Both mutants above fail +there — 16 and 18 failures, exit 1. So a reporter defect shows up as `fjs t` +disagreeing with the external runners, never as every gate lying together — +and `fjs t`'s own exit code is trustworthy only in that company. ### Why the remaining steps are worth taking From 291f3c18742aa2ad51a223e3ed3246d01f0456c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:06:37 +0000 Subject: [PATCH 143/370] todo: point the todo/skip counter designs at RunTotals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-reporter change replaced TestState with RunTotals and its ad-hoc updaters with the addResult fold; todo-property.md and skip-property.md still directed their new counters at the old record. They now extend RunTotals through addResult — and since Reporter.summary receives the whole record, the todo counter no longer needs the signature change the old design assumed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/emergent_testing/todo/skip-property.md | 4 ++-- fjs/emergent_testing/todo/todo-property.md | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/fjs/emergent_testing/todo/skip-property.md b/fjs/emergent_testing/todo/skip-property.md index 7578993ce..806a62e47 100644 --- a/fjs/emergent_testing/todo/skip-property.md +++ b/fjs/emergent_testing/todo/skip-property.md @@ -110,7 +110,7 @@ option on the test effect used by the surviving process-based adapters: zero-arg function is still a leaf; generators are not expanded (they are leaves that never run). - **`runModule`** — when `entry.skip`, do not call `test`; report a skipped - result and increment the `skip` counter in `TestState` (no `pass`/`fail` + result and increment the `skip` counter in `RunTotals` (no `passed`/`failed` change, no return-value walk). - **`registerModule`** — register skipped leaves with a `skip` flag instead of a test body; no subtest registration, no ` ...` star suffix. @@ -150,7 +150,7 @@ Playwright execution obtains skip results from the shared browser application. - [ ] Add `skip` to `TestEntry`; inherit it in `parseTestSet` / `collectTests` like `throws`. - [ ] Short-circuit skipped leaves in `runModule` (no execution, no walk) and - count them in a new `TestState.skip`. + count them in a new `RunTotals.skip` (folded in `addResult`). - [ ] Extend the test-effect options with `skip`; map it to Node, Deno, and Bun without adding or restoring a Playwright branch in the Node effect runner. - [ ] Implement equivalent skip collection and reporting in the shared diff --git a/fjs/emergent_testing/todo/todo-property.md b/fjs/emergent_testing/todo/todo-property.md index fa2b9f4d1..b59a811db 100644 --- a/fjs/emergent_testing/todo/todo-property.md +++ b/fjs/emergent_testing/todo/todo-property.md @@ -186,12 +186,13 @@ docs, mirroring the existing `throws` plumbing: instead of a bare `throws` boolean) so `defaultReporter` can annotate a passing leaf with `# TODO` when `todo` is set, otherwise keep `# EXPECTED TO THROW`. The GitHub/error path is unchanged. -- **`TestState` / summary (`fjs t` only)** — add a `todo` counter to `TestState`, - incremented for every `todo` leaf in `runModule` (independently of whether it - passed or failed; its pass/fail still increments `pass`/`fail` as usual). - Extend `Reporter.summary` to receive the `todo` count and have `defaultReporter` - print it (`pass / fail / todo`). The `register` path is unchanged — no - cross-test tally there. +- **`RunTotals` / summary (`fjs t` only)** — add a `todo` counter to + `RunTotals`, folded in `addResult` for every `todo` leaf (independently of + whether it passed or failed; its pass/fail still increments + `passed`/`failed` as usual). `Reporter.summary` already receives the whole + record, so no signature change — `defaultReporter` just prints the new field + (`pass / fail / todo`). The `register` path is unchanged — no cross-test + tally there. - **No `fn.name === 'todo'` check** — `todo` is structural-key-only, matching the guidance around the legacy `fn.name === 'throw'` path. @@ -228,7 +229,7 @@ out from under `throw`) as part of landing this change. skipping + star suffix to `leafOnly` (`runModule`, `registerModule`). - [ ] Update `Reporter.result` to receive the entry flags and annotate passing `todo` leaves with `# TODO`. -- [ ] Add a `todo` counter to `TestState`/`Reporter.summary` and print +- [ ] Add a `todo` counter to `RunTotals` (folded in `addResult`) and print `pass / fail / todo` in `defaultReporter` (`fjs t` only; `register` unchanged). - [ ] Migrate `fjs/emergent_testing/example.f.mjs` off `throw: { todo }`. From 293926ffa1baa8920992be6dbbccba8b91ab51ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:22:54 +0000 Subject: [PATCH 144/370] changelog: trim #1748's two longest entries to the per-entry guideline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `changelog/README.md` bounds an *entry* — one list item; a pull request with several entries puts them all in its one file — at about three wrapped lines, ~250 characters. Two of the four ran past that; now every item is within it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/1748.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/changelog/unreleased/1748.md b/changelog/unreleased/1748.md index 8f9c904c7..b057e9078 100644 --- a/changelog/unreleased/1748.md +++ b/changelog/unreleased/1748.md @@ -1,14 +1,13 @@ - **BREAKING CHANGES:** `rtti`: `option` is a nullary schema denoting - **absence** — a member that may be omitted is `or(option, t)`, and `{}` is - no longer the same set as `{ a: undefined }`. The old `option(t)` set is - `or(option, t, undefined)`; plain `or(option, t)` rejects a present - `undefined`. + **absence** — an omittable member is `or(option, t)`, which rejects a + present `undefined`; the old `option(t)` set is `or(option, t, undefined)`. + `{}` and `{ a: undefined }` are distinct sets. - `rtti`: `parse` omits an absent member — the struct kind drops the key, the array kind keeps holes and shortens a trailing absent run — so an optional member survives a JSON round-trip. -- `rtti`: `unknown` excludes absence, so a member declared `unknown` is - required and the omittable top is `or(option, unknown)`. `Ts<>`, the - runtime printer and `toJsonSchema` derive optionality (`?`, `required`, - `minItems`) from absence — exact under `exactOptionalPropertyTypes`. +- `rtti`: `unknown` excludes absence — the omittable top is + `or(option, unknown)`. `Ts<>`, the runtime printer and `toJsonSchema` + derive optionality (`?`, `required`, `minItems`) from absence, exact under + `exactOptionalPropertyTypes`. - `rtti`: a `Phantom` annotation on a schema whose root admits absence carries the new `Absent` marker, pinned with the new `CheckRaw`. From f0ca31bc2aa3b9390555ce19b30c7114f04e2d30 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:23:19 +0000 Subject: [PATCH 145/370] ci: guard the file-scope typedef prohibition; shorter changelog entry The node26 invariants job now fails when any authored .mjs contains a file-scope JSDoc typedef, so the 226-to-0 declaration-leak result stays guarded instead of merely achieved. Verified against a probe typedef in an untouched module: clean tree passes, probe fails. The changelog entry is trimmed toward the ~250-character guideline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 3 +++ changelog/unreleased/1750.md | 9 ++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 069271364..28df92cb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -498,6 +498,9 @@ { "run": "git add -A && git diff --cached --exit-code" }, + { + "run": "! grep -rnE '^(/\\*\\*.*@typedef| \\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." + }, { "run": "npx tsc" }, diff --git a/changelog/unreleased/1750.md b/changelog/unreleased/1750.md index a8fa95a75..9bc53641c 100644 --- a/changelog/unreleased/1750.md +++ b/changelog/unreleased/1750.md @@ -1,6 +1,5 @@ -- **BREAKING CHANGES:** named types moved out of authored `.mjs`: `Grammar` now - imports from `fjs/fsm/types.ts`; `MemoryOperationMap`, `MemoryRun`, and - `Uuid` from `fjs/effects/node/memory/types.ts`; `BrowserTestReport` from +- **BREAKING CHANGES:** type import paths changed: `Grammar` → + `fjs/fsm/types.ts`; `MemoryOperationMap`, `MemoryRun`, `Uuid` → + `fjs/effects/node/memory/types.ts`; `BrowserTestReport` → `fjs/emergent_testing/types.ts`; the JSON-Schema `Unknown` alias is gone — - spell it `Ts`. Private `_` types now live in `types.ts` or - `private.ts`, never as file-scope typedefs in `.mjs`. + spell it `Ts`. From ec77b6293f2b4a9eb29188d2b77034cb614e5cbb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:25:53 +0000 Subject: [PATCH 146/370] ci: generate the typedef guard instead of hand-editing ci.yml ci.yml is generated by npm run ci-update, so the guard added directly to it was erased by regeneration and failed the diff check. The step now comes from node26Steps in fjs/ci/node/module.f.mjs; the regenerated ci.yml is byte-identical to the previous hand edit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/node/module.f.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fjs/ci/node/module.f.mjs b/fjs/ci/node/module.f.mjs index ecc355138..6cd04b189 100644 --- a/fjs/ci/node/module.f.mjs +++ b/fjs/ci/node/module.f.mjs @@ -64,6 +64,10 @@ const node26Steps = [ ...nodeInstall(node.default), test({ run: 'npm run ci-update' }), test({ run: 'git add -A && git diff --cached --exit-code' }), + // No authored `.mjs` may contain a file-scope JSDoc `@typedef` (root + // `AGENTS.md`); `tsc` accepts one silently, so the prohibition needs its + // own gate. + test({ run: "! grep -rnE '^(/\\*\\*.*@typedef| \\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." }), test({ run: 'npx tsc' }), test({ run: 'npm run cov' }), test({ run: 'npm pack' }), From 4b8c1d9142c952f420d8a872b761c4bbafea05a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:47:40 +0000 Subject: [PATCH 147/370] ci: catch tab-indented typedef continuations too The guard required a literal space before the continuation asterisk, so a JSDoc block indented with a tab slipped past; \s closes that gap without matching function-local blocks, whose continuations are indented by four or more characters. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 2 +- fjs/ci/node/module.f.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28df92cb4..ed8518a62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -499,7 +499,7 @@ "run": "git add -A && git diff --cached --exit-code" }, { - "run": "! grep -rnE '^(/\\*\\*.*@typedef| \\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." + "run": "! grep -rnE '^(/\\*\\*.*@typedef|\\s\\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." }, { "run": "npx tsc" diff --git a/fjs/ci/node/module.f.mjs b/fjs/ci/node/module.f.mjs index 6cd04b189..57c6aaf58 100644 --- a/fjs/ci/node/module.f.mjs +++ b/fjs/ci/node/module.f.mjs @@ -67,7 +67,7 @@ const node26Steps = [ // No authored `.mjs` may contain a file-scope JSDoc `@typedef` (root // `AGENTS.md`); `tsc` accepts one silently, so the prohibition needs its // own gate. - test({ run: "! grep -rnE '^(/\\*\\*.*@typedef| \\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." }), + test({ run: "! grep -rnE '^(/\\*\\*.*@typedef|\\s\\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." }), test({ run: 'npx tsc' }), test({ run: 'npm run cov' }), test({ run: 'npm pack' }), From 1d8bd77eb481085399d077cf7930cccefb204741 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:47:48 +0000 Subject: [PATCH 148/370] rtti: parse's rebuilds dispatch nothing overridable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading a member of the value can run arbitrary code — an accessor — and the rebuild runs after every read, so by then that code may have replaced anything reached by dynamic lookup: an `Array.prototype` method, the array iterator, `Object.fromEntries`, the `Array` binding, or the species lookup inside every array method — even a captured `concat` builds through the receiver's species. A getter that patched `Array.prototype.concat` steered the tuple rebuild into `['ok', []]` for `[1, 2]` against `[number, number]`. The rebuilds now walk the entry cons list the loop already builds — plain literals, property reads only — and place members with `Object.defineProperty` captured at module load, which consults nothing patchable; `eachEntry` walks by index for the same reason, `for..of` dispatching the patchable iterator. The intermediate array and the pairwise `concat` join are gone with the dispatch. Pinned in `host.proof.mjs` across all three container kinds. The verdict path still dispatches overridable operations after a read — recorded as `fjs/rtti/todo/hostile-accessor-hermetic-read-path.md`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- fjs/rtti/common/module.f.mjs | 23 ++- fjs/rtti/host.proof.mjs | 69 +++++++ fjs/rtti/parse/module.f.mjs | 178 +++++++++++------- .../hostile-accessor-hermetic-read-path.md | 54 ++++++ 4 files changed, 251 insertions(+), 73 deletions(-) create mode 100644 fjs/rtti/todo/hostile-accessor-hermetic-read-path.md diff --git a/fjs/rtti/common/module.f.mjs b/fjs/rtti/common/module.f.mjs index e0bdd519f..7a3d07002 100644 --- a/fjs/rtti/common/module.f.mjs +++ b/fjs/rtti/common/module.f.mjs @@ -106,10 +106,18 @@ export const isObject = * final accumulator. * * Used by `parse`'s container builders (array/record/tuple/struct), which - * need the rebuilt `[key, value]` pairs, so they fold them into a `List` (see - * the call site) and convert to an array once at the end. A caller whose - * whole question is "did every entry succeed?" passes `undefined`/`acc => acc` + * need the rebuilt `[key, value]` pairs, so they fold them onto a cons list + * (see the call site) their rebuilds walk directly. A caller whose whole + * question is "did every entry succeed?" passes `undefined`/`acc => acc` * instead and pays no allocation per entry. + * + * The walk is by index rather than `for..of`: `item` reads the value, and a + * read can run an accessor that replaces `Array.prototype`'s iterator — + * which `for..of` and destructuring dispatch on every step, so a later + * step's `[k, v]` was the accessor's to choose. Index and `length` reads + * consult nothing overridable on these plain entry arrays — the same rule + * `parse`'s rebuilds state in full (see `defineProperty` in + * `../parse/module.f.mjs`). */ export const eachEntry = /** @@ -124,12 +132,13 @@ export const eachEntry = */ (entries, item, init, accumulate) => { let acc = init - for (const [k, v] of entries) { - const r = item(k, v) + for (let i = 0; i < entries.length; i += 1) { + const e = entries[i] + const r = item(e[0], e[1]) if (r[0] === 'error') { - return prependPath(k, r) + return prependPath(e[0], r) } - acc = accumulate(acc, k, r[1]) + acc = accumulate(acc, e[0], r[1]) } return ok(acc) } diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index 23ccaf6ca..12bea47b5 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -199,6 +199,75 @@ export const proof = { assert(r[0] === 'ok', 'expected ok') assertStructurallySame(/** @type {readonly unknown[]} */ (r[1]), [1]) }, + // …and never dispatches an overridable operation at all. Reading a + // member can run arbitrary code — an accessor — and the rebuild runs + // after every read, so a getter that patches `Array.prototype.concat` + // (or `map`, `flatMap`, `slice`, `Object.fromEntries`) has patched it + // before any rebuild executes: a rebuild dispatching one of them was + // handed `['ok', []]` for `[1, 2]` against `[number, number]`. The + // fixed rebuilds construct with `defineProperty` captured at module + // load and walk their own cons list by property reads, so none of + // these patches reaches what `parse` builds. (The *verdict* path still + // dispatches overridable operations after a read — that exposure is + // `todo/hostile-accessor-hermetic-read-path.md`, and this fixture + // patches only what corrupts no verdict here.) + hostileIntrinsicPatchesDoNotReachTheRebuild: () => { + const captured = { + concat: Array.prototype.concat, + flatMap: Array.prototype.flatMap, + map: Array.prototype.map, + slice: Array.prototype.slice, + fromEntries: Object.fromEntries, + } + const patch = () => { + Array.prototype.concat = () => [] + Array.prototype.flatMap = () => [] + Array.prototype.map = () => [] + Array.prototype.slice = () => [] + Object.fromEntries = () => ({}) + } + const restore = () => { + Array.prototype.concat = captured.concat + Array.prototype.flatMap = captured.flatMap + Array.prototype.map = captured.map + Array.prototype.slice = captured.slice + Object.fromEntries = captured.fromEntries + } + /** @type {(v: Unknown) => () => Unknown} */ + const patchingGetter = v => () => { patch(); return v } + // The original repro: an index-0 getter that patches and returns `1`. + const tupleValue = [0, 2] + Object.defineProperty(tupleValue, 0, { + get: patchingGetter(1), + enumerable: true, + configurable: true, + }) + const rt = p([number, number])(tupleValue) + restore() + assert(rt[0] === 'ok', 'expected ok') + assertStructurallySame(/** @type {readonly unknown[]} */ (rt[1]), [1, 2]) + // The struct kind's `fromEntries` and the uniform array kind's + // `map` were the same seam. + const structValue = Object.defineProperty({ b: 2 }, 'a', { + get: patchingGetter(1), + enumerable: true, + configurable: true, + }) + const rs = p({ a: number, b: number })(structValue) + restore() + assert(rs[0] === 'ok', 'expected ok') + assertStructurallySame(rs[1], { a: 1, b: 2 }) + const arrayValue = [0, 2] + Object.defineProperty(arrayValue, 0, { + get: patchingGetter(1), + enumerable: true, + configurable: true, + }) + const ra = p(array(number))(arrayValue) + restore() + assert(ra[0] === 'ok', 'expected ok') + assertStructurallySame(/** @type {readonly unknown[]} */ (ra[1]), [1, 2]) + }, // …and `parse` **materializes** the inherited value as an own member of // what it builds: the member is *present* — HasProperty is what the // check dispatched on — so its parsed value is in the entries the diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index ff1f46ec0..aec04cd73 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -50,14 +50,12 @@ * @import { ConstObject, Info1, Tag1, Type } from '../types.ts' * @import { Result as CommonResult } from '../../types/result/types.ts' * @import { StringMap } from '../../types/object/types.ts' - * @import { List } from '../../types/list/types.ts' * @import { Container, Fits, IsContainer, SchemaEntries, ValidateE, ValidationError, Visitor } from '../common/types.ts' * @import { Unknown } from '../ts/types.ts' * @import { Parse } from './types.ts' */ import { ok } from '../../types/result/module.f.mjs' -import { reverse, toArray } from '../../types/list/module.f.mjs' import { absentMember, constPrimitiveValidate, @@ -76,81 +74,134 @@ import { emptyRest } from '../data/module.f.mjs' /** @typedef {CommonResult} _ItemResult */ -/** Rebuilds a parsed container from its `[key, parsedValue]` entries. */ -/** @typedef {(entries: ReadonlyArray) => Unknown} _Rebuild */ +/** + * The parsed `[key, parsedValue]` pairs as {@link consEntry} and + * {@link consPresent} fold them: a cons list in **reverse** member order, so + * its head is the last member parsed — for the array kinds, the highest + * present index. + */ +/** @typedef {null | { readonly first: readonly [string, Unknown], readonly tail: _Entries }} _Entries */ -/** @type {_Rebuild} */ -const arrayRebuild = entries => entries.map(([, v]) => v) +/** Rebuilds a parsed container from its entries. */ +/** @typedef {(entries: _Entries) => Unknown} _Rebuild */ +/** + * The rebuilds' one construction step, captured at module load. + * + * Reading a member of the value can run **arbitrary code** — an accessor — + * and the rebuild runs after every read, so by then that code may have + * replaced anything the language reaches by dynamic lookup: an + * `Array.prototype` method (`concat`, `map`, `flatMap`), the array + * iterator every `for..of` and destructuring dispatches, + * `Object.fromEntries`, the `Array` binding `new Array` resolves, or the + * `constructor`/`@@species` lookup inside every array method — even a + * *captured* `concat` builds its result through the receiver's species. A + * rebuild dispatching any of those was steered into `['ok', …]` values + * failing the very schema they were parsed against — see + * `../host.proof.mjs`. + * + * `defineProperty` on a fresh container consults none of that: it creates + * an own data property directly, the array exotic length update included. + * So the rebuilds walk the entry cons list — plain literals this module + * built — by property reads alone, place members with this one captured + * operation, and perform no other dynamic lookup at all (`+k` is the + * index read, `Number` being a patchable global). + */ +const { defineProperty } = Object + +/** The one `Array` the rebuilds construct with, captured at module load. */ +const PlainArray = Array + +/** The descriptor a literal would create: an enumerable own data property. */ +/** @type {(value: Unknown) => PropertyDescriptor} */ +const enumerableValue = value => + ({ value, writable: true, enumerable: true, configurable: true }) + +/** Restores member order from the reverse-order entries, in one linear pass. */ +/** @type {(entries: _Entries) => _Entries} */ +const reverseEntries = entries => { + /** @type {_Entries} */ + let r = null + for (let n = entries; n !== null; n = n.tail) { + r = { first: n.first, tail: r } + } + return r +} + +/** + * The uniform **array** kind's rebuild: the parsed elements, dense, in + * member order — placed back to front, since the entries arrive reversed. + */ /** @type {_Rebuild} */ -const recordRebuild = entries => Object.fromEntries(entries) +const arrayRebuild = entries => { + let length = 0 + for (let n = entries; n !== null; n = n.tail) { length += 1 } + const result = new PlainArray(length) + let i = length + for (let n = entries; n !== null; n = n.tail) { + i -= 1 + defineProperty(result, i, enumerableValue(n.first[1])) + } + return result +} /** - * One pairwise round of {@link tupleRebuild}'s join: adjacent segments are - * `concat`enated — always one argument, so no call ever spreads the segment - * list — halving the count while copying every element once. `concat` - * appends a spreadable operand element by *present* element, which is what - * keeps the holes; each receiver is a trusted plain array, so its species - * is `Array`. - * - * @type {(segments: ReadonlyArray>) => ReadonlyArray>} + * The **record** and **struct** kinds' rebuild: the parsed members as a fresh + * plain object, in member order — an absent declared member left no entry, + * so dropping its key needs nothing more. A key is *defined*, never + * assigned: assignment dispatches setters up the chain (`'__proto__'` + * among them), which is the same dynamic surface the rebuilds exist to + * avoid. */ -const joinSegmentsRound = segments => segments.flatMap((s, i) => - i % 2 !== 0 ? [] - : i + 1 < segments.length ? [s.concat(segments[i + 1])] - : [s]) +/** @type {_Rebuild} */ +const recordRebuild = entries => { + const result = {} + for (let n = reverseEntries(entries); n !== null; n = n.tail) { + defineProperty(result, n.first[0], enumerableValue(n.first[1])) + } + return result +} /** * The **tuple** kind's rebuild over its declared members — only the present - * ones reach `entries` (`recordRebuild` is the struct kind's counterpart, - * where dropping an absent key needs nothing more): the present members at - * their own indices, holes at the absent ones before them, ending at the - * last present position — so a trailing absent run shortens the result and - * an interior hole survives (materializing it as `undefined` would denote a - * different value, and omitting it would shift every position after it). + * ones reach `entries`: each at its own index, holes at the absent ones + * before them, ending at the last present position — so a trailing absent + * run shortens the result and an interior hole survives (materializing it + * as `undefined` would denote a different value, and omitting it would + * shift every position after it). The reversed entries' head *is* the last + * present position, so the length is known before the walk, and an index + * never defined stays a hole of `new PlainArray`'s making. * - * The construction is segments — a fresh `new Array(gap)` of holes before - * each present member, then the member — collected on an O(1)-prepend list - * and joined pairwise ({@link joinSegmentsRound}), so the whole rebuild is - * one linear pass plus a logarithmic number of halving rounds: re-spreading - * the accumulated segments per entry was quadratic, and one - * `concat(...segments)` call overflowed the engine's argument limit on a - * large enough prefix, throwing past the `Result` API. - * - * The input value is never consulted, and every array touched is a plain - * one this module made, which is the point: an earlier slice-then-map of - * the input let an accepted `Array` subclass override `slice` and hand - * `parse` a result that fails the very schema it was parsed against. An - * index the value only *inherits* is a present member (HasProperty is what - * the check dispatched on), so it sits in `entries` and is materialized as - * an own member of the result, carrying its parsed value — see + * The input value is never consulted, and nothing overridable is + * dispatched — see {@link defineProperty} above for why both matter: an + * accepted value supplied first a `slice` of its own and then, through an + * accessor, a patched `Array.prototype.concat`, and each steered a rebuild + * into a result that fails the schema it was parsed against. An index the + * value only *inherits* is a present member (HasProperty is what the check + * dispatched on), so it sits in `entries` and is materialized as an own + * member of the result, carrying its parsed value — see * `../host.proof.mjs`. * * @type {_Rebuild} */ const tupleRebuild = entries => { - /** @type {List>} */ - let reversed = null - let next = 0 - for (const [k, v] of entries) { - const i = Number(k) - if (i > next) { reversed = { first: new Array(i - next), tail: reversed } } - reversed = { first: [v], tail: reversed } - next = i + 1 - } - let segments = toArray(reverse(reversed)) - while (segments.length > 1) { - segments = joinSegmentsRound(segments) + if (entries === null) { return [] } + const result = new PlainArray(+entries.first[0] + 1) + /** @type {_Entries} */ + let n = entries + while (n !== null) { + defineProperty(result, n.first[0], enumerableValue(n.first[1])) + n = n.tail } - return segments.length === 0 ? [] : segments[0] + return result } /** `eachEntry`'s accumulator seed: entries are consed on in reverse as they parse. */ -/** @type {List} */ +/** @type {_Entries} */ const emptyEntries = null /** `eachEntry`'s accumulate step: an O(1) prepend, unlike rebuilding an array on every entry. */ -/** @type {(acc: List, k: string, v: Unknown) => List} */ +/** @type {(acc: _Entries, k: string, v: Unknown) => _Entries} */ const consEntry = (acc, k, v) => ({ first: [k, v], tail: acc }) @@ -162,7 +213,7 @@ const consEntry = (acc, k, v) => * `undefined` included, is a legal parse result, so no value could mark * absence. */ -/** @type {(acc: List, k: string, vs: ReadonlyArray) => List} */ +/** @type {(acc: _Entries, k: string, vs: ReadonlyArray) => _Entries} */ const consPresent = (acc, k, vs) => vs.length === 0 ? acc : ({ first: [k, vs[0]], tail: acc }) @@ -170,11 +221,6 @@ const consPresent = (acc, k, vs) => /** @type {readonly string[]} */ const noDeclared = [] -/** Restores forward order from `consEntry`'s reverse-order list, in one linear pass. */ -/** @type {(list: List) => ReadonlyArray} */ -const orderedEntries = list => - toArray(reverse(list)) - /** * Builds a parser for `array` or `record` schemas: rebuilds a fresh container * from each item's parsed result. The inner item parser is instantiated lazily @@ -208,12 +254,12 @@ const containerParse = const e = undeclaredMembers(noDeclared, value) if (e.length === 0) { return fits(value, 0) - ? /** @type {any} */ (ok(rebuild([]))) + ? /** @type {any} */ (ok(rebuild(null))) : verror('unexpected value') } const itemParse = /** @type {any} */ (parse(item)) const r = eachEntry(e, (_k, v) => itemParse(v), emptyEntries, consEntry) - return r[0] === 'error' ? r : /** @type {any} */ (ok(rebuild(orderedEntries(r[1])))) + return r[0] === 'error' ? r : /** @type {any} */ (ok(rebuild(r[1]))) } } @@ -287,7 +333,7 @@ const constContainerParse = ) if (r[0] === 'error') { return r } return undeclaredMembers(declared, value).length === 0 && fits(value, declared.length) - ? /** @type {any} */ (ok(rebuild(orderedEntries(r[1])))) + ? /** @type {any} */ (ok(rebuild(r[1]))) : verror('unexpected value') } } @@ -357,12 +403,12 @@ const restContainerParse = const extra = undeclaredMembers(declared, value) if (extra.length === 0) { return fits(value, declared.length) - ? ok(rebuild(orderedEntries(d[1]))) + ? ok(rebuild(d[1])) : verror('unexpected value') } const restParse = /** @type {any} */ (parse(r)) const e = eachEntry(extra, (_k, v) => restParse(v), undefined, noAccumulate) - return e[0] === 'error' ? e : ok(rebuild(orderedEntries(d[1]))) + return e[0] === 'error' ? e : ok(rebuild(d[1])) } } diff --git a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md new file mode 100644 index 000000000..c907c79a1 --- /dev/null +++ b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md @@ -0,0 +1,54 @@ +# The readers' verdict path dispatches overridable operations after a read + +**Priority:** P2 +**Status:** open + +## Problem + +Reading a member of a hostile value can run **arbitrary code** — an accessor +— and everything a reader does after that read trusts whatever the accessor +left behind. `parse`'s *rebuilds* no longer dispatch anything overridable +(see `defineProperty` in [`../parse/module.f.mjs`](../parse/module.f.mjs) +and `hostileIntrinsicPatchesDoNotReachTheRebuild` in +[`../host.proof.mjs`](../host.proof.mjs)), but the **verdict** path still +does, in [`../common/module.f.mjs`](../common/module.f.mjs) and its callers: + +- `undeclaredMembers`/`readIndices` build their member list with + `Object.entries`, `.filter`, `.map`, `.flatMap`, `.toSorted`, `.indexOf` + and array spreads — for the const kinds this runs *after* the declared + members were read, so a patching accessor steers which members the closed + check or a `rest` sees. The tuple length bound catches the simplest + variant, but a `rest` kind can be steered into accepting a value whose + undeclared members were never held to the rest. +- `visit` and `absenceIn` destructure the schema thunk's descriptor + (`const [tag, ...operands] = rtti()`), which dispatches + `Array.prototype[Symbol.iterator]` — patched, the accessor chooses the + tag, and with it the verdict. `absenceIn` also relies on `.some` and an + array spread; `orVisit` iterates its variants with `for..of`. +- `prependPath` spreads `r.path`, so a patched iterator can throw from the + error path, escaping the `Result` API. +- Globals resolved at call time — `Number`, `String`, `Object`, `Array` — + are reassignable through `globalThis` by the same accessor + (`arrayIndex`, `getItem`, `Object.entries` call sites). + +A wrong *accept* here is a plausible wrong value; the boundary is that the +accessor has already run arbitrary code in the host, so this hardening is +about the readers' own answers staying theirs, not about containing the +host. + +## Tasks + +- [ ] Extend the discipline the rebuilds and `eachEntry` state to the + post-read functions of `common/module.f.mjs`: capture the intrinsics + used (`Object.entries`, `Object.getOwnPropertyNames`, + `Object.getPrototypeOf`, `Object.hasOwn`, `Number.isInteger`, the + `Array`/`Number`/`String` bindings) at module load, and replace + `for..of`, destructuring, spreads and array methods on those paths + with index walks and cons/`defineProperty` construction. +- [ ] `readIndices`' sort for inherited indices needs a captured or + hand-rolled ordering; the dedup's shape is pinned by its JSDoc. +- [ ] Keep behavior bit-identical for non-patching values: the three-reader + tables and `../host.proof.mjs` pin member order and the + non-index/beyond-`length` rules. +- [ ] Pin each closed hole in `../host.proof.mjs` the way the rebuild fix + is pinned, restoring every patched intrinsic before asserting. From 5a5825f21a91e36c3f26b3355fc8554ac1c34dbb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 07:49:14 +0000 Subject: [PATCH 149/370] changelog: three wrapped lines per entry for #1748 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/1748.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/changelog/unreleased/1748.md b/changelog/unreleased/1748.md index b057e9078..8c0ac784b 100644 --- a/changelog/unreleased/1748.md +++ b/changelog/unreleased/1748.md @@ -1,13 +1,11 @@ - **BREAKING CHANGES:** `rtti`: `option` is a nullary schema denoting - **absence** — an omittable member is `or(option, t)`, which rejects a - present `undefined`; the old `option(t)` set is `or(option, t, undefined)`. - `{}` and `{ a: undefined }` are distinct sets. + **absence**: an omittable member is `or(option, t)`, which rejects a present + `undefined` — the old `option(t)` set is `or(option, t, undefined)`. - `rtti`: `parse` omits an absent member — the struct kind drops the key, the array kind keeps holes and shortens a trailing absent run — so an optional member survives a JSON round-trip. -- `rtti`: `unknown` excludes absence — the omittable top is - `or(option, unknown)`. `Ts<>`, the runtime printer and `toJsonSchema` - derive optionality (`?`, `required`, `minItems`) from absence, exact under - `exactOptionalPropertyTypes`. +- `rtti`: `unknown` excludes absence, so the omittable top is + `or(option, unknown)`; `Ts<>`, the runtime printer and `toJsonSchema` derive + optionality (`?`, `required`, `minItems`) from absence. - `rtti`: a `Phantom` annotation on a schema whose root admits absence carries the new `Absent` marker, pinned with the new `CheckRaw`. From fed8c16e26030ae6c8f8774b826ca1b44f10c01b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:02:44 +0000 Subject: [PATCH 150/370] rtti: parse refuses what prototype pollution makes unbuildable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A getter on a later declared member can install an earlier, already omitted declared key on `Object.prototype` (or an omitted position on `Array.prototype`), and then every fresh container inherits it: the member is present by the same HasProperty rule the readers dispatch on, so no plain container the rebuild could hand back denotes the value that was checked — `parse` returned `['ok', { b: 2 }]` whose payload fails the very schema it was parsed against. The declared-member kinds now check the omission's postcondition after the rebuild: every declared key the entries omitted must still be absent from what was built — `in` plus `Object.hasOwn` captured at module load, both internal operations, so the check itself dispatches nothing overridable — and refuse otherwise, per DESIGN.md §10. Pinned in `host.proof.mjs` for the struct, tuple and rest kinds. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- fjs/rtti/host.proof.mjs | 49 +++++++++++++++++++++++++++++++++++ fjs/rtti/parse/module.f.mjs | 51 +++++++++++++++++++++++++++++++------ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index 12bea47b5..393ce36ee 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -268,6 +268,55 @@ export const proof = { assert(ra[0] === 'ok', 'expected ok') assertStructurallySame(/** @type {readonly unknown[]} */ (ra[1]), [1, 2]) }, + // …and when the accessor **pollutes a prototype** instead, `parse` + // refuses. A getter on a later member can install an earlier, already + // omitted declared key on `Object.prototype` (or an omitted position on + // `Array.prototype`), and then every fresh container *inherits* it: the + // member is present by the same HasProperty rule the readers dispatch + // on, so no plain container the rebuild could hand back denotes the + // value that was checked — a success here carried a payload failing the + // very schema it was parsed against. Refusing is `omittedStillAbsent`'s + // case: the omission's postcondition no longer holds. + parseRefusesWhatPollutionMakesUnbuildable: () => { + const structValue = { b: 0 } + Object.defineProperty(structValue, 'b', { + get: () => { + /** @type {any} */ (Object.prototype).a = 'bad' + return 2 + }, + enumerable: true, + configurable: true, + }) + const rs = p({ a: or(option, number), b: number })(structValue) + delete (/** @type {any} */ (Object.prototype).a) + assertError(rs) + const tupleValue = [, 0] + Object.defineProperty(tupleValue, 1, { + get: () => { + /** @type {any} */ (Array.prototype)[0] = 'bad' + return 2 + }, + enumerable: true, + configurable: true, + }) + const rt = p([or(option, number), number])(tupleValue) + delete (/** @type {any} */ (Array.prototype))[0] + assertError(rt) + // …and the `rest` kinds decide omission the same way, so they hold + // the same postcondition. + const restValue = { b: 0 } + Object.defineProperty(restValue, 'b', { + get: () => { + /** @type {any} */ (Object.prototype).a = 'bad' + return 2 + }, + enumerable: true, + configurable: true, + }) + const rr = p(rest({ a: or(option, number) }, number))(restValue) + delete (/** @type {any} */ (Object.prototype).a) + assertError(rr) + }, // …and `parse` **materializes** the inherited value as an own member of // what it builds: the member is *present* — HasProperty is what the // check dispatched on — so its parsed value is in the entries the diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index aec04cd73..6b18b0779 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -221,6 +221,32 @@ const consPresent = (acc, k, vs) => /** @type {readonly string[]} */ const noDeclared = [] +/** The declared-member kinds' postcondition check's one lookup, captured at module load. */ +const { hasOwn } = Object + +/** + * Whether every declared member the rebuild **omitted** is still absent from + * `built` — the postcondition the omission was decided on. The accessor a + * member read can run may install the omitted key on `Object.prototype` (or + * an omitted position on `Array.prototype`), and then every fresh container + * *inherits* it: the member is present by the same HasProperty rule the + * readers dispatch on, so what was built no longer denotes the value that + * was checked — no plain container can, which is `verror`'s case, not a + * different construction's (see `../host.proof.mjs`). An omitted member + * reads `k in built` false; a present one is the rebuild's own; only an + * inherited declared key is the environment having changed underneath the + * parse. Both operations are internal — `in` runs no accessor — so the + * check itself dispatches nothing overridable. + * + * @type {(declared: readonly string[], built: ReadonlyArray | StringMap) => boolean} + */ +const omittedStillAbsent = (declared, built) => { + for (let i = 0; i < declared.length; i += 1) { + if (declared[i] in built && !hasOwn(built, declared[i])) { return false } + } + return true +} + /** * Builds a parser for `array` or `record` schemas: rebuilds a fresh container * from each item's parsed result. The inner item parser is instantiated lazily @@ -332,8 +358,12 @@ const constContainerParse = consPresent, ) if (r[0] === 'error') { return r } - return undeclaredMembers(declared, value).length === 0 && fits(value, declared.length) - ? /** @type {any} */ (ok(rebuild(r[1]))) + if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { + return verror('unexpected value') + } + const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(r[1])) + return omittedStillAbsent(declared, built) + ? /** @type {any} */ (ok(built)) : verror('unexpected value') } } @@ -402,13 +432,18 @@ const restContainerParse = if (d[0] === 'error') { return d } const extra = undeclaredMembers(declared, value) if (extra.length === 0) { - return fits(value, declared.length) - ? ok(rebuild(d[1])) - : verror('unexpected value') + if (!fits(value, declared.length)) { + return verror('unexpected value') + } + } else { + const restParse = /** @type {any} */ (parse(r)) + const e = eachEntry(extra, (_k, v) => restParse(v), undefined, noAccumulate) + if (e[0] === 'error') { return e } } - const restParse = /** @type {any} */ (parse(r)) - const e = eachEntry(extra, (_k, v) => restParse(v), undefined, noAccumulate) - return e[0] === 'error' ? e : ok(rebuild(d[1])) + const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(d[1])) + return omittedStillAbsent(declared, built) + ? ok(built) + : verror('unexpected value') } } From 08100561691b2d7718362f7630606cd24020ffd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:20:02 +0000 Subject: [PATCH 151/370] rtti: a Phantom annotation carries absence in a wrapper, never a union member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Absent | unknown` *is* `unknown`: a union-carried marker drowns when the annotation's present part renders as the top — `Ts<{}>` is native `unknown`, `StructTs`'s intersection identity — so `or(option, {})` behind a `Phantom` silently rendered its member required while the readers accept `{}`, and the union-shaped `CheckRaw` passed anyway, `_TsRaw` collapsing identically on both sides. The annotation now spells absence as the new `AbsentOr` wrapper — the same shape the runtime keeps, the data form's absent bit riding beside the union rather than in it — which survives any present type. `_AdmitsAbsence`, `_IsAbsentOnly`, `Ts` and `_TsRaw` read the wrapper first, and `CheckRaw` pins the flag half separately against the schema's structural answer, so the unwrapped spelling now fails the pin even at the top instead of passing silently. An absent-only root is `AbsentOr`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/1748.md | 4 +- fjs/rtti/ts/types.ts | 179 +++++++++++++++++++++++------------ fjs/types/phantom/types.ts | 19 ++-- 3 files changed, 134 insertions(+), 68 deletions(-) diff --git a/changelog/unreleased/1748.md b/changelog/unreleased/1748.md index 8c0ac784b..fc43b4781 100644 --- a/changelog/unreleased/1748.md +++ b/changelog/unreleased/1748.md @@ -7,5 +7,5 @@ - `rtti`: `unknown` excludes absence, so the omittable top is `or(option, unknown)`; `Ts<>`, the runtime printer and `toJsonSchema` derive optionality (`?`, `required`, `minItems`) from absence. -- `rtti`: a `Phantom` annotation on a schema whose root admits absence - carries the new `Absent` marker, pinned with the new `CheckRaw`. +- `rtti`: a `Phantom` annotation on a schema whose root admits absence wraps + its present part in the new `AbsentOr`, pinned with the new `CheckRaw`. diff --git a/fjs/rtti/ts/types.ts b/fjs/rtti/ts/types.ts index 7f58ed042..ce99fc017 100644 --- a/fjs/rtti/ts/types.ts +++ b/fjs/rtti/ts/types.ts @@ -25,37 +25,60 @@ declare const absentKey: unique symbol * `undefined` (which would make `or(undefined, number)` optional too and * conflate the very pair `option` exists to separate). * - * It appears only in {@link _TsRaw} results and in a `Phantom` annotation's - * raw shape; the public {@link Ts} strips it, and every container position - * lowers it for itself — a struct key or trailing tuple position renders - * optional, an interior tuple position renders `undefined` (what reading a - * hole gives), an array or record element excludes it. One caveat is - * inherent: the top absorbs it — `Absent` is assignable to `unknown`, and to - * the `Object` arm of {@link Unknown} — so no subtype query over a rendered - * type can recover it. Whether a member may be absent is therefore asked of - * the *schema*, by {@link _AdmitsAbsence}, never of the rendered union. + * It appears only in {@link _TsRaw} results; the public {@link Ts} strips + * it, and every container position lowers it for itself — a struct key or + * trailing tuple position renders optional, an interior tuple position + * renders `undefined` (what reading a hole gives), an array or record + * element excludes it. One caveat is inherent: the top absorbs it — + * `Absent` is assignable to `unknown`, and `Absent | unknown` *is* + * `unknown` — so neither a subtype query over a rendered type nor a union + * member can carry absence past a top-rendering present part. Whether a + * member may be absent is therefore asked of the *schema*, by + * {@link _AdmitsAbsence}, never of the rendered union — and a `Phantom` + * annotation carries it in {@link AbsentOr}'s wrapper, never as a union + * member. */ export type Absent = { readonly [absentKey]: typeof absentKey } +/** + * A `Phantom` annotation's spelling for a schema whose **root admits + * absence**: `AbsentOr` wraps the present part instead of unioning + * {@link Absent} into it, because a union member drowns in a top-rendering + * present part — `Absent | unknown` is `unknown`, and `or(option, {})` + * renders its present part as `unknown` (see {@link StructTs}) — while the + * branded wrapper survives any present type. This is the same shape the + * runtime keeps: the data form's absent bit rides *beside* the union, never + * in it. {@link _AdmitsAbsence}, {@link _IsAbsentOnly}, {@link Ts} and + * {@link _TsRaw} all read the wrapper first; {@link CheckRaw} pins its + * presence against the schema. An absent-only root — `option` itself — + * annotates as `AbsentOr`. + */ +export type AbsentOr = { readonly [absentKey]: T } + /** * Whether the schema type admits **absence** — the type-level counterpart of * `admitsAbsence` in `../common/module.f.mjs`, and the predicate * {@link StructTs} and {@link TupleTs} decide optionality with. Structural * over the schema: it recurses through `or` — which does no flattening, so * `or(or(option, number), string)` needs the recursion — and reads a - * `Phantom` annotation's raw shape for its `Absent` member. It is *not* a + * `Phantom` annotation for its {@link AbsentOr} wrapper. It is *not* a * subtype query against the rendered type: neither `Absent extends Ts<…>` * (false for every member — `Ts` strips the marker) nor * `Absent extends _TsRaw<…>` (true at `unknown`, whose top absorbs the * marker) can answer it — `{ a: unknown }`, which rejects `{}`, would render * indistinguishably from `{ a: or(option, unknown) }`, which accepts it. + * Nor is it a union-membership query over the annotation: + * `Extract` read absence out of `Absent | number`, but + * `Absent | unknown` has already collapsed to `unknown` — the marker + * drowned with nothing to extract, and the member rendered required. The + * wrapper is what survives a top-rendering present part. */ export type _AdmitsAbsence = unknown extends T ? false : true extends _AdmitsAbsence1 ? true : false type _AdmitsAbsence1 = - T extends { readonly [phantomKey]?: infer O } ? ([Extract] extends [never] ? false : true) : + T extends { readonly [phantomKey]?: infer O } ? ([O] extends [{ readonly [absentKey]: unknown }] ? true : false) : T extends () => infer I ? I extends readonly['option'] ? true : I extends readonly['or', ...infer A extends readonly Type[]] ? _AdmitsAbsence1 @@ -73,18 +96,20 @@ type _AdmitsAbsence1 = * A `Phantom` annotation is read **before** the thunk walk, exactly as * {@link _AdmitsAbsence} and `Ts` read it: a phantom-wrapped schema is still * a thunk, so descending its `or` chain would re-expand the very recursion - * the annotation exists to spare (TS2589). The annotation is `_TsRaw`-shaped, - * so its present part is what survives `Exclude` — - * the same `Exclude` the public `Ts` applies — and "absent-only" is that - * part being `never`. (`undefined` there is the optional-field artifact the - * `Phantom` contract already excludes from annotations, not a value member.) + * the annotation exists to spare (TS2589). An absence-admitting root + * annotates as {@link AbsentOr}``, so "absent-only" is a wrapper + * whose present part is `never` — `AbsentOr` — and an unwrapped + * annotation admits no absence at all. (`undefined` is stripped as the + * optional-field artifact the `Phantom` contract already excludes from + * annotations, not as a value member.) */ type _IsAbsentOnly = unknown extends T ? false : false extends _IsAbsentOnly1 ? false : true type _IsAbsentOnly1 = - T extends { readonly [phantomKey]?: infer O } ? ([Exclude] extends [never] ? true : false) : + T extends { readonly [phantomKey]?: infer O } + ? ([O] extends [{ readonly [absentKey]: infer P }] ? ([Exclude] extends [never] ? true : false) : false) : T extends () => infer I ? I extends readonly['option'] ? true : I extends readonly['or', ...infer A extends readonly Type[]] ? _IsAbsentOnly1 @@ -389,16 +414,19 @@ export type StructTs = * type _Check = Assert> * ``` * - * **A schema whose root admits absence needs one more assert.** The - * annotation is {@link _TsRaw}-shaped, so when the wrapped schema's root is - * `or(option, …)` it must carry the {@link Absent} marker — - * `Phantom` — or the member it is used at - * renders required. The pair above cannot catch the omission: both compare - * through the public `Ts`, which strips `Absent` from both sides. Pin the - * raw half with {@link CheckRaw}: + * **A schema whose root admits absence needs one more assert.** When the + * wrapped schema's root is `or(option, …)` the annotation must carry the + * flag in {@link AbsentOr}'s wrapper — + * `Phantom>` — or the member it is used at + * renders required. The wrapper, not a union: `Absent | MyType` drowns when + * `MyType` renders as the top (`Absent | unknown` *is* `unknown`), and the + * marker takes the optionality with it. The pair above cannot catch the + * omission either way: both compare through the public `Ts`, which strips + * absence from both sides. Pin the flag and the present part together with + * {@link CheckRaw}: * * ```ts - * type _CheckRaw = Assert> + * type _CheckRaw = Assert, typeof myThunk>> * ``` * * See `fjs/edag/module.f.mjs` (`_exp`/`exp`) for this in practice. Note also @@ -426,9 +454,11 @@ export type Ts = unknown extends T ? Unknown : // Phantom output: if the schema carries a phantomKey annotation (via WithOut), return // it directly — one indexed-access, no structural walk, no TS2589 for recursive - // schemas. The annotation is `_TsRaw`-shaped, so the `Absent` marker is stripped - // here alongside the optional-field `undefined` artifact. - T extends { readonly [phantomKey]?: infer O } ? Exclude : + // schemas. An absence-admitting root annotates as `AbsentOr`, so the + // wrapper is unwrapped here; either way the optional-field `undefined` + // artifact is stripped. + T extends { readonly [phantomKey]?: infer O } + ? ([O] extends [{ readonly [absentKey]: infer P }] ? Exclude : Exclude) : T extends () => infer I ? ( I extends readonly['const', infer C] ? ConstTs : // Info0 @@ -457,16 +487,19 @@ export type Ts = * The {@link Absent}-preserving counterpart of {@link Ts}, differing only at * the **root** of a schema — the one place absence has no container position * to lower it into: `_TsRaw` is `Absent | number` - * where the public `Ts` is `number`. It walks `or` chains and reads a - * `Phantom` annotation verbatim (minus the optional-field `undefined` - * artifact), and delegates every other form to `Ts` — container positions - * lower the marker for themselves, so below the root the two agree. Its two - * consumers are a `Phantom` annotation's shape and {@link CheckRaw}, which - * pins one. + * where the public `Ts` is `number`. It walks `or` chains and unwraps a + * `Phantom` annotation's {@link AbsentOr} back into that union shape (minus + * the optional-field `undefined` artifact), and delegates every other form + * to `Ts` — container positions lower the marker for themselves, so below + * the root the two agree. Note the union shape *collapses at the top* + * (`Absent | unknown` is `unknown`), which is exactly why an annotation + * spells absence as the wrapper and why {@link CheckRaw} pins the flag + * separately rather than through this union. */ export type _TsRaw = unknown extends T ? Unknown : - T extends { readonly [phantomKey]?: infer O } ? Exclude : + T extends { readonly [phantomKey]?: infer O } + ? ([O] extends [{ readonly [absentKey]: infer P }] ? Absent | Exclude : Exclude) : T extends () => infer I ? ( I extends readonly['option'] ? Absent : I extends readonly['or', ...infer A extends readonly Type[]] ? _TsRaw : @@ -493,18 +526,28 @@ export type Check = Equal> export type Check3 = And>, Equal>> /** - * The **raw** counterpart of {@link Check}: pins `A` against - * {@link _TsRaw}``, the {@link Absent}-preserving shape. This is the - * assert with teeth for a `Phantom` annotation on a schema whose root admits - * absence: {@link Check} and {@link Check3} compare through the public - * {@link Ts}, which strips `Absent` from *both* sides, so they pass even - * when the annotation forgot the marker — and the wrapped member then - * renders required. Spell the annotation `Absent | …` and add - * `Assert>` beside the usual pair; a - * schema whose root excludes absence needs nothing new, `_TsRaw` and `Ts` - * agreeing there. - */ -export type CheckRaw = Equal> + * The **raw** counterpart of {@link Check}: pins a `Phantom` annotation `A` + * against the schema `B` in both halves — the **flag** ({@link AbsentOr}'s + * wrapper is present on `A` exactly when `B`'s root admits absence, + * structurally) and the **present part** (`A`'s, against `_TsRaw` with + * the marker stripped). This is the assert with teeth for a schema whose + * root admits absence: {@link Check} and {@link Check3} compare through the + * public {@link Ts}, which strips absence from *both* sides, so they pass + * even when the annotation forgot the wrapper — and the wrapped member then + * renders required. The flag half is deliberately not a comparison through + * `_TsRaw`'s union, where `Absent | unknown` has already collapsed and a + * missing marker passed: spell the annotation `AbsentOr<…>` and add + * `Assert, typeof rawThunk>>` beside the usual pair; a + * schema whose root excludes absence needs nothing new — its annotation is + * unwrapped, and this then agrees with {@link Check} on the raw thunk. + */ +export type CheckRaw = And< + Equal<[A] extends [{ readonly [absentKey]: unknown }] ? true : false, _AdmitsAbsence>, + Equal< + [A] extends [{ readonly [absentKey]: infer P }] ? P : A, + Exclude<_TsRaw, Absent> + > +> // Fast-path: Ts resolves to Unknown without TS2589 overflow. type _any = Assert> @@ -696,7 +739,7 @@ type _restEmptyIndirect = Assert> type _optionUnion = Assert>> -type _optionUnionRaw = Assert>> +type _optionUnionRaw = Assert, Or>> /** * The type-level counterpart of "a rest never sees it": an array or record @@ -718,21 +761,41 @@ type _nestedOptionKey = Assert> /** - * A `Phantom` annotation is {@link _TsRaw}-shaped: wrapping a schema whose - * root admits absence, it carries {@link Absent}, which - * {@link _AdmitsAbsence} reads from the annotation and the container - * position lowers — the wrapped member renders optional. {@link CheckRaw} - * is the assert with teeth for the annotation itself: the {@link Check} - * pair passes with or without the marker, both halves stripping it. + * A `Phantom` annotation on a schema whose root admits absence carries the + * flag in {@link AbsentOr}'s wrapper, which {@link _AdmitsAbsence} reads + * from the annotation and the container position lowers — the wrapped + * member renders optional. {@link CheckRaw} is the assert with teeth for + * the annotation itself: the {@link Check} pair passes with or without the + * wrapper, both halves stripping absence. */ -type _PhantomOption = Phantom, Absent | number> -type _phantomRaw = Assert>> +type _PhantomOption = Phantom, AbsentOr> +type _phantomRaw = Assert, Or>> type _phantomPublic = Assert> type _phantomOptionalMember = Assert> +/** + * The wrapper's reason to exist: a present part that renders as the **top** + * absorbs a union member — `Absent | Ts<{}>` is `unknown`, {@link StructTs} + * rendering the empty struct as its `unknown` intersection identity — so a + * union-carried marker drowned, the member rendered required, and the + * union-shaped `CheckRaw` passed anyway, `_TsRaw` collapsing identically on + * both sides. The wrapper survives the collapse, and the flag half of + * {@link CheckRaw} fails the unwrapped spelling even at the top. + */ +type _PhantomTopOption = Phantom, AbsentOr> +type _phantomTopRaw = Assert, Or>> +type _phantomTopMember = Assert> +type _phantomTopUnwrappedFails = Assert>, + false +>> + /** * The `Phantom` short-circuit holds at every structural predicate, not only * in `Ts`: a phantom-wrapped schema is still a thunk, so a predicate that @@ -744,7 +807,7 @@ type _phantomOptionalMember = Assert readonly['or', RttiOption, RttiNumber, _PhantomRecThunk] -type _PhantomRec = Phantom<_PhantomRecThunk, Absent | number> +type _PhantomRec = Phantom<_PhantomRecThunk, AbsentOr> type _phantomRecursiveArray = Assert readonly['array', _PhantomRec] @@ -755,5 +818,5 @@ type _phantomRecursiveMember = Assert> type _phantomAbsentOnlyArray = Assert readonly['array', Phantom] + () => readonly['array', Phantom>] >> diff --git a/fjs/types/phantom/types.ts b/fjs/types/phantom/types.ts index 8028eacda..ed13afe80 100644 --- a/fjs/types/phantom/types.ts +++ b/fjs/types/phantom/types.ts @@ -37,16 +37,19 @@ export type { phantomKey } * type _Check = Assert> * ``` * - * For an rtti schema the annotation is **`_TsRaw`-shaped**: when the wrapped - * schema's *root* admits absence — `or(option, …)` — `T` must carry the - * `Absent` marker (`Absent | MyType`), or a member the wrapped schema is - * used at silently renders required. The pair above cannot catch the - * omission, both halves comparing through the public `Ts<>`, which strips - * `Absent` from both sides — so such a schema **requires** the raw assert - * beside them, with `CheckRaw` and `Absent` from `fjs/rtti/ts/types.ts`: + * For an rtti schema: when the wrapped schema's *root* admits absence — + * `or(option, …)` — `T` must carry the flag in the `AbsentOr` wrapper + * (`AbsentOr`), or a member the wrapped schema is used at silently + * renders required. A wrapper rather than an `Absent | MyType` union, + * because a union member drowns when `MyType` renders as the top — + * `Absent | unknown` *is* `unknown` — taking the optionality with it. The + * pair above cannot catch the omission, both halves comparing through the + * public `Ts<>`, which strips absence from both sides — so such a schema + * **requires** the raw assert beside them, with `CheckRaw` and `AbsentOr` + * from `fjs/rtti/ts/types.ts`: * * ```ts - * type _CheckRaw = Assert> + * type _CheckRaw = Assert, typeof rawThunk>> * ``` * * A schema whose root excludes absence needs nothing new — `_TsRaw` and From 14918766c3da691cd5312cf3ced40f05debec7c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 09:19:52 +0000 Subject: [PATCH 152/370] rtti: the three readers refuse a mid-read presence flip identically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pollution refusal lived in `parse` alone, so the readers disagreed on exactly that input class: a getter installing an omitted key on `Object.prototype` was refused by `parse` while `validate` and the data form handed back a value that no longer denotes what was checked — and a value one reader accepts and another rejects is a bug in whichever walk differs, by `host.proof.mjs`'s own table. Every declared-member walk now records each member's presence and re-asks it last (`presenceUnchanged` in the shared kernel), after everything that reads the value, refusing on any flip in either direction — an omitted key made present by pollution, or a checked own key deleted by a later accessor. Pinned across all three readers, both directions, all declared-member kinds. The one deliberate residual split is a value with **no prototype**: pollution cannot flip its own absence, so the hands-back readers return it — still a faithful member of the set — while `parse`, whose every plain container now inherits the omitted key, refuses per DESIGN.md §10. Pinned as such. The changelog entry the hardening was missing is added. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y8fxzY1KUThQy8GMWNoGWp --- changelog/unreleased/1748.md | 3 + fjs/rtti/common/module.f.mjs | 42 +++++- fjs/rtti/common/types.ts | 8 ++ fjs/rtti/data/module.f.mjs | 75 +++++++---- fjs/rtti/host.proof.mjs | 124 ++++++++++++------ fjs/rtti/parse/module.f.mjs | 41 ++++-- .../hostile-accessor-hermetic-read-path.md | 7 +- fjs/rtti/validate/module.f.mjs | 59 ++++++--- 8 files changed, 261 insertions(+), 98 deletions(-) diff --git a/changelog/unreleased/1748.md b/changelog/unreleased/1748.md index fc43b4781..75e07942a 100644 --- a/changelog/unreleased/1748.md +++ b/changelog/unreleased/1748.md @@ -9,3 +9,6 @@ optionality (`?`, `required`, `minItems`) from absence. - `rtti`: a `Phantom` annotation on a schema whose root admits absence wraps its present part in the new `AbsentOr`, pinned with the new `CheckRaw`. +- `rtti`: `parse` builds its result without dispatching any overridable + operation, and all three readers refuse a value whose accessors flip a + decided member's presence mid-read, instead of answering wrongly. diff --git a/fjs/rtti/common/module.f.mjs b/fjs/rtti/common/module.f.mjs index 7a3d07002..85861c59a 100644 --- a/fjs/rtti/common/module.f.mjs +++ b/fjs/rtti/common/module.f.mjs @@ -36,7 +36,7 @@ * @import { Const, Info0, Primitive0, Struct, Tag1, Tuple, Type } from '../types.ts' * @import { Error, Result as CommonResult } from '../../types/result/types.ts' * @import { StringMap } from '../../types/object/types.ts' - * @import { Validate, Visitor, IsContainer, Container, ResultE, SchemaEntries, ValidateE, ValidationError } from './types.ts' + * @import { Validate, Visitor, IsContainer, Container, Presence, ResultE, SchemaEntries, ValidateE, ValidationError } from './types.ts' */ import { assert } from '../../asserts/module.f.mjs' @@ -143,6 +143,46 @@ export const eachEntry = return ok(acc) } +/** {@link consPresence}'s seed and {@link presenceUnchanged}'s empty walk. */ +/** @type {Presence} */ +export const emptyPresence = null + +/** + * `eachEntry`'s accumulate step recording each declared member's + * **presence** — the item's `ok` payload, `true` for a member the walk saw + * present — one cons per member, newest first. + */ +/** @type {(acc: Presence, k: string, present: boolean) => Presence} */ +export const consPresence = (acc, _k, present) => + ({ first: present, tail: acc }) + +/** + * Whether each declared member's presence is still what the walk saw — the + * postcondition every absence decision was made under. A member's read can + * run an accessor, and a later member's accessor can flip an *earlier*, + * already decided member: install the omitted key on `Object.prototype` + * (or an omitted position on `Array.prototype`) and the member is present + * by the same HasProperty rule the walk dispatched on; delete an own key + * and a checked member is gone. Either way the verdict is stale — a + * hands-back reader would return a value that no longer denotes what was + * checked, and a constructing one built from decisions that no longer hold + * — so every reader re-asks the one question last, after everything that + * reads the value, and refuses on any flip. `reversed` is the walk's + * answers newest-first and exactly one per declared member, so the + * comparison walks `entries` from its end in lockstep; `in` runs no + * accessor, so the recheck itself reads nothing of the value's. + * + * @type {(entries: ReadonlyArray, reversed: Presence, value: ReadonlyArray | StringMap) => boolean} + */ +export const presenceUnchanged = (entries, reversed, value) => { + let i = entries.length + for (let n = reversed; n !== null; n = n.tail) { + i -= 1 + if ((entries[i][0] in value) !== n.first) { return false } + } + return true +} + /** * What a `Tuple` schema declares, read by **length**. * diff --git a/fjs/rtti/common/types.ts b/fjs/rtti/common/types.ts index 8c6055200..7c1079e5e 100644 --- a/fjs/rtti/common/types.ts +++ b/fjs/rtti/common/types.ts @@ -85,5 +85,13 @@ export type Container = K extends 'array' /** `Result` with the payload type erased; avoids instantiating `Ts`. */ export type ResultE = CommonResult +/** + * The presence bits a declared-member walk saw — one boolean per declared + * member, consed newest-first by `consPresence` in `module.f.mjs`, so the + * list is the walk's answers in reverse declared order. `presenceUnchanged` + * is the consumer. + */ +export type Presence = null | { readonly first: boolean, readonly tail: Presence } + /** A `Validate`-shaped function with the payload type erased. */ export type ValidateE = (value: Unknown) => ResultE diff --git a/fjs/rtti/data/module.f.mjs b/fjs/rtti/data/module.f.mjs index 14a137eb6..76e8d8d9a 100644 --- a/fjs/rtti/data/module.f.mjs +++ b/fjs/rtti/data/module.f.mjs @@ -25,7 +25,7 @@ import { assert, assertNotNullish } from '../../asserts/module.f.mjs' import { at, definedEntries, definedValues } from '../../types/object/module.f.mjs' import { ok } from '../../types/result/module.f.mjs' -import { eachEntry, isArray, undeclaredMembers, verror } from '../common/module.f.mjs' +import { consPresence, eachEntry, emptyPresence, isArray, presenceUnchanged, undeclaredMembers, verror } from '../common/module.f.mjs' /** * The unit kind's enumeration: bit `1 << i` of a {@link UnionSet}'s `unit` @@ -1244,13 +1244,18 @@ const patternsValidate = (k, item, value) => { const arraySetValidate = rules => p => value => { const pn = p.prefix.length const { rest } = p + const prefixEntries = Object.entries(p.prefix) const declared = eachEntry( - Object.entries(p.prefix), - (k, n) => k in value - ? nodeValidate(rules)(n)(value[Number(k)]) - : nodeAdmitsAbsence(rules)(n) ? ok(undefined) : verror('unexpected value'), - undefined, - noAccumulate, + prefixEntries, + (k, n) => { + if (!(k in value)) { + return nodeAdmitsAbsence(rules)(n) ? ok(false) : verror('unexpected value') + } + const m = nodeValidate(rules)(n)(value[Number(k)]) + return m[0] === 'error' ? m : ok(true) + }, + emptyPresence, + consPresence, ) if (declared[0] === 'error') { return declared } const extra = undeclaredMembers(p.prefix.map((_, i) => String(i)), value) @@ -1261,34 +1266,52 @@ const arraySetValidate = rules => p => value => { // Schema as `items: false`. A *shorter* array is another matter — the // declared loop above has already held every position it left unfilled // to a set admitting `undefined`. - return extra.length === 0 && value.length <= pn - ? ok(value) - : verror('unexpected value') + if (extra.length !== 0 || value.length > pn) { + return verror('unexpected value') + } + } else { + const r = eachEntry(extra, (_k, v) => nodeValidate(rules)(rest)(v), undefined, noAccumulate) + if (r[0] === 'error') { return r } } - const r = eachEntry(extra, (_k, v) => nodeValidate(rules)(rest)(v), undefined, noAccumulate) - return r[0] === 'error' ? r : ok(value) + // Re-asked last, after everything that reads the value: a member's + // accessor can flip an earlier, already decided position's presence — + // the same postcondition the schema-form readers hold, so the three + // readers refuse the flip identically (see `../host.proof.mjs`). + return presenceUnchanged(prefixEntries, declared[1], value) + ? ok(value) + : verror('unexpected value') } /** @type {(rules: RuleSet) => (p: ObjectSet) => (value: StringMap) => ResultE} */ const objectSetValidate = rules => p => value => { + const propEntries = definedEntries(p.props) const declared = eachEntry( - definedEntries(p.props), - (k, n) => k in value - ? nodeValidate(rules)(n)(value[k]) - : nodeAdmitsAbsence(rules)(n) ? ok(undefined) : verror('unexpected value'), - undefined, - noAccumulate, + propEntries, + (k, n) => { + if (!(k in value)) { + return nodeAdmitsAbsence(rules)(n) ? ok(false) : verror('unexpected value') + } + const m = nodeValidate(rules)(n)(value[k]) + return m[0] === 'error' ? m : ok(true) + }, + emptyPresence, + consPresence, ) if (declared[0] === 'error') { return declared } const { rest } = p - if (rest === undefined) { return ok(value) } - const extra = eachEntry( - Object.entries(value).filter(([k]) => at(k)(p.props) === null), - (_k, v) => nodeValidate(rules)(rest)(v), - undefined, - noAccumulate, - ) - return extra[0] === 'error' ? extra : ok(value) + if (rest !== undefined) { + const extra = eachEntry( + Object.entries(value).filter(([k]) => at(k)(p.props) === null), + (_k, v) => nodeValidate(rules)(rest)(v), + undefined, + noAccumulate, + ) + if (extra[0] === 'error') { return extra } + } + // The same last re-ask as `arraySetValidate`'s, one kind over. + return presenceUnchanged(propEntries, declared[1], value) + ? ok(value) + : verror('unexpected value') } /** @type {(rules: RuleSet) => (u: UnionSet) => (value: Unknown) => ResultE} */ diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index 393ce36ee..0277b0b2b 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -19,6 +19,7 @@ * * @import { Type } from './types.ts' * @import { ValidateE } from './common/types.ts' + * @import { StringMap } from '../types/object/types.ts' * @import { Unknown } from './ts/types.ts' */ @@ -268,54 +269,93 @@ export const proof = { assert(ra[0] === 'ok', 'expected ok') assertStructurallySame(/** @type {readonly unknown[]} */ (ra[1]), [1, 2]) }, - // …and when the accessor **pollutes a prototype** instead, `parse` - // refuses. A getter on a later member can install an earlier, already - // omitted declared key on `Object.prototype` (or an omitted position on - // `Array.prototype`), and then every fresh container *inherits* it: the - // member is present by the same HasProperty rule the readers dispatch - // on, so no plain container the rebuild could hand back denotes the - // value that was checked — a success here carried a payload failing the - // very schema it was parsed against. Refusing is `omittedStillAbsent`'s - // case: the omission's postcondition no longer holds. - parseRefusesWhatPollutionMakesUnbuildable: () => { - const structValue = { b: 0 } - Object.defineProperty(structValue, 'b', { - get: () => { - /** @type {any} */ (Object.prototype).a = 'bad' - return 2 - }, + // …and when the accessor **flips the presence** of an already decided + // member instead, every reader refuses — identically, which is the + // agreement this file's tables exist to hold. A later member's getter + // can install an earlier, omitted key on `Object.prototype` (or an + // omitted position on `Array.prototype`) — the member is then present + // by the same HasProperty rule the walk dispatched on — or delete a + // checked own key; either way the verdict was made under a presence + // that no longer holds: a hands-back reader would return a value that + // no longer denotes what was checked, and the constructing one would + // build from stale decisions. All three re-ask presence last + // (`presenceUnchanged`), after everything that reads the value. + presenceFlipsAreRefusedByAllReaders: () => { + /** @type {(pollute: () => void) => StringMap} */ + const structWith = pollute => Object.defineProperty({ b: 0 }, 'b', { + get: () => { pollute(); return 2 }, enumerable: true, configurable: true, }) - const rs = p({ a: or(option, number), b: number })(structValue) - delete (/** @type {any} */ (Object.prototype).a) - assertError(rs) - const tupleValue = [, 0] - Object.defineProperty(tupleValue, 1, { - get: () => { - /** @type {any} */ (Array.prototype)[0] = 'bad' - return 2 - }, - enumerable: true, - configurable: true, - }) - const rt = p([or(option, number), number])(tupleValue) - delete (/** @type {any} */ (Array.prototype))[0] - assertError(rt) - // …and the `rest` kinds decide omission the same way, so they hold - // the same postcondition. - const restValue = { b: 0 } - Object.defineProperty(restValue, 'b', { - get: () => { - /** @type {any} */ (Object.prototype).a = 'bad' - return 2 - }, + const polluteObject = () => { /** @type {any} */ (Object.prototype).a = 'bad' } + const unpolluteObject = () => { delete (/** @type {any} */ (Object.prototype).a) } + for (const read of [v, p, d]) { + // absent → present, struct + const rs = read({ a: or(option, number), b: number })(structWith(polluteObject)) + unpolluteObject() + assertError(rs) + // absent → present, tuple + const tv = [, 0] + Object.defineProperty(tv, 1, { + get: () => { /** @type {any} */ (Array.prototype)[0] = 'bad'; return 2 }, + enumerable: true, + configurable: true, + }) + const rt = read([or(option, number), number])(tv) + delete (/** @type {any} */ (Array.prototype))[0] + assertError(rt) + // absent → present behind a stated rest — the `rest` kinds + // decide omission the same way, so they hold the same + // postcondition + const rr = read(rest({ a: or(option, number) }, number))(structWith(polluteObject)) + unpolluteObject() + assertError(rr) + // present → absent: a later getter deletes a checked own member + /** @type {any} */ + let dv = { a: 1, b: 0 } + dv = Object.defineProperty(dv, 'b', { + get: () => { delete dv.a; return 2 }, + enumerable: true, + configurable: true, + }) + assertError(read({ a: number, b: number })(dv)) + } + }, + // A value with **no prototype** is the residual split, and a deliberate + // one: pollution cannot flip such a value's own absence — the omitted + // key still reads nothing anywhere on its (empty) chain — so the + // hands-back readers return the value, still a faithful member of the + // set. `parse` builds plain containers, and every plain container now + // *inherits* the omitted key, so nothing it could build denotes the + // value it checked: it refuses (`omittedStillAbsent`), per DESIGN.md + // §10. Each reader honest to its own contract — return what was given, + // or build only what the schema still accepts. + nullPrototypePollutionSplitsByContract: () => { + const schema = { a: or(option, number), b: number } + /** @type {() => StringMap} */ + const make = () => Object.defineProperty(Object.create(null), 'b', { + get: () => { /** @type {any} */ (Object.prototype).a = 'bad'; return 2 }, enumerable: true, configurable: true, }) - const rr = p(rest({ a: or(option, number) }, number))(restValue) - delete (/** @type {any} */ (Object.prototype).a) - assertError(rr) + const unpollute = () => { delete (/** @type {any} */ (Object.prototype).a) } + const rv = v(schema)(make()) + unpollute() + assertOk(rv) + const rd = d(schema)(make()) + unpollute() + assertOk(rd) + const rp = p(schema)(make()) + unpollute() + assertError(rp) + // …and the same split one kind over, behind a stated rest. + const restSchema = rest({ a: or(option, number) }, number) + const rrv = v(restSchema)(make()) + unpollute() + assertOk(rrv) + const rrp = p(restSchema)(make()) + unpollute() + assertError(rrp) }, // …and `parse` **materializes** the inherited value as an own member of // what it builds: the member is *present* — HasProperty is what the diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 6b18b0779..d155ac0c6 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -50,7 +50,7 @@ * @import { ConstObject, Info1, Tag1, Type } from '../types.ts' * @import { Result as CommonResult } from '../../types/result/types.ts' * @import { StringMap } from '../../types/object/types.ts' - * @import { Container, Fits, IsContainer, SchemaEntries, ValidateE, ValidationError, Visitor } from '../common/types.ts' + * @import { Container, Fits, IsContainer, Presence, SchemaEntries, ValidateE, ValidationError, Visitor } from '../common/types.ts' * @import { Unknown } from '../ts/types.ts' * @import { Parse } from './types.ts' */ @@ -63,6 +63,7 @@ import { isArray, isObject, orVisit, + presenceUnchanged, primitive0Validate, structSchemaEntries, tupleSchemaEntries, @@ -76,7 +77,7 @@ import { emptyRest } from '../data/module.f.mjs' /** * The parsed `[key, parsedValue]` pairs as {@link consEntry} and - * {@link consPresent} fold them: a cons list in **reverse** member order, so + * {@link consDeclared} fold them: a cons list in **reverse** member order, so * its head is the last member parsed — for the array kinds, the highest * present index. */ @@ -205,17 +206,27 @@ const emptyEntries = null const consEntry = (acc, k, v) => ({ first: [k, v], tail: acc }) +/** What the declared-member fold carries: the present entries, and every member's presence bit. */ +/** @typedef {{ readonly entries: _Entries, readonly presence: Presence }} _Declared */ + +/** {@link consDeclared}'s seed. */ +/** @type {_Declared} */ +const emptyDeclared = { entries: null, presence: null } + /** * `eachEntry`'s accumulate step over *declared* members, whose item wraps a * present member's parsed value in a one-element list and an absent member * in an empty one: the present value is kept, the absent member leaves no * entry. The wrapping is what stands in for a sentinel — every value, * `undefined` included, is a legal parse result, so no value could mark - * absence. + * absence. The presence bit is kept for every member either way — it is + * what `presenceUnchanged` re-asks after everything that reads the value. */ -/** @type {(acc: _Entries, k: string, vs: ReadonlyArray) => _Entries} */ -const consPresent = (acc, k, vs) => - vs.length === 0 ? acc : ({ first: [k, vs[0]], tail: acc }) +/** @type {(acc: _Declared, k: string, vs: ReadonlyArray) => _Declared} */ +const consDeclared = (acc, k, vs) => ({ + entries: vs.length === 0 ? acc.entries : { first: [k, vs[0]], tail: acc.entries }, + presence: { first: vs.length !== 0, tail: acc.presence }, +}) /** A uniform container declares no member by name, so every one is undeclared. */ /** @type {readonly string[]} */ @@ -354,14 +365,17 @@ const constContainerParse = const p = /** @type {any} */ (parse(t))(getItem(value, k)) return p[0] === 'error' ? p : ok([p[1]]) }, - emptyEntries, - consPresent, + emptyDeclared, + consDeclared, ) if (r[0] === 'error') { return r } if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { return verror('unexpected value') } - const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(r[1])) + if (!presenceUnchanged(rttiEntries, r[1].presence, value)) { + return verror('unexpected value') + } + const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(r[1].entries)) return omittedStillAbsent(declared, built) ? /** @type {any} */ (ok(built)) : verror('unexpected value') @@ -426,8 +440,8 @@ const restContainerParse = const p = /** @type {any} */ (parse(t))(getItem(value, k)) return p[0] === 'error' ? p : ok([p[1]]) }, - emptyEntries, - consPresent, + emptyDeclared, + consDeclared, ) if (d[0] === 'error') { return d } const extra = undeclaredMembers(declared, value) @@ -440,7 +454,10 @@ const restContainerParse = const e = eachEntry(extra, (_k, v) => restParse(v), undefined, noAccumulate) if (e[0] === 'error') { return e } } - const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(d[1])) + if (!presenceUnchanged(rttiEntries, d[1].presence, value)) { + return verror('unexpected value') + } + const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(d[1].entries)) return omittedStillAbsent(declared, built) ? ok(built) : verror('unexpected value') diff --git a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md index c907c79a1..b6f68391f 100644 --- a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md +++ b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md @@ -10,8 +10,11 @@ Reading a member of a hostile value can run **arbitrary code** — an accessor left behind. `parse`'s *rebuilds* no longer dispatch anything overridable (see `defineProperty` in [`../parse/module.f.mjs`](../parse/module.f.mjs) and `hostileIntrinsicPatchesDoNotReachTheRebuild` in -[`../host.proof.mjs`](../host.proof.mjs)), but the **verdict** path still -does, in [`../common/module.f.mjs`](../common/module.f.mjs) and its callers: +[`../host.proof.mjs`](../host.proof.mjs)), and all three readers re-ask +each declared member's presence last (`presenceUnchanged` in +[`../common/module.f.mjs`](../common/module.f.mjs)), but the **verdict** +path still dispatches overridable operations, in the same module and its +callers: - `undeclaredMembers`/`readIndices` build their member list with `Object.entries`, `.filter`, `.map`, `.flatMap`, `.toSorted`, `.indexOf` diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index ae482ec7f..5fd40c9d5 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -85,11 +85,14 @@ import { ok } from '../../types/result/module.f.mjs' import { absentMember, + consPresence, constPrimitiveValidate, eachEntry, + emptyPresence, isArray, isObject, orVisit, + presenceUnchanged, primitive0Validate, structSchemaEntries, tupleSchemaEntries, @@ -184,6 +187,13 @@ const recordValidate = containerValidate(isObject, () => () => true) * `{ a: undefined }`. An absent member is legal exactly when its schema * admits absence (`admitsAbsence` in `../common/module.f.mjs`); a present * one is dispatched as before. + * + * The decisions are **re-asked last** (`presenceUnchanged`): a member's + * read can run an accessor that flips an earlier, already decided member — + * prototype pollution makes an omitted key present, a delete makes a + * checked one absent — and the value handed back would no longer denote + * what was checked. The three readers refuse the flip identically — see + * `../host.proof.mjs`. */ const constContainerValidate = /** @@ -207,16 +217,24 @@ const constContainerValidate = } const r = eachEntry( rttiEntries, - (k, v) => k in value - ? /** @type {any} */ (validate(v))(getItem(value, k)) - : absentMember(v), - undefined, - noAccumulate, + (k, v) => { + if (!(k in value)) { + const a = absentMember(v) + return a[0] === 'error' ? a : ok(false) + } + const m = /** @type {any} */ (validate(v))(getItem(value, k)) + return m[0] === 'error' ? m : ok(true) + }, + emptyPresence, + consPresence, ) if (r[0] === 'error') { return r } + if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { + return verror('unexpected value') + } // `value` is C (Unknown container), but Ts for T extends Tuple|Struct is not // structurally equivalent to C — TypeScript can't narrow element types through the loop. - return undeclaredMembers(declared, value).length === 0 && fits(value, declared.length) + return presenceUnchanged(rttiEntries, r[1], value) ? /** @type {any} */ (ok(value)) : verror('unexpected value') } @@ -270,20 +288,31 @@ const restContainerValidate = } const d = eachEntry( rttiEntries, - (k, v) => k in value - ? /** @type {any} */ (validate(v))(getItem(value, k)) - : absentMember(v), - undefined, - noAccumulate, + (k, v) => { + if (!(k in value)) { + const a = absentMember(v) + return a[0] === 'error' ? a : ok(false) + } + const m = /** @type {any} */ (validate(v))(getItem(value, k)) + return m[0] === 'error' ? m : ok(true) + }, + emptyPresence, + consPresence, ) if (d[0] === 'error') { return d } const extra = undeclaredMembers(declared, value) if (extra.length === 0) { - return fits(value, declared.length) ? ok(value) : verror('unexpected value') + if (!fits(value, declared.length)) { + return verror('unexpected value') + } + } else { + const restValidate = /** @type {any} */ (validate(r)) + const e = eachEntry(extra, (_k, v) => restValidate(v), undefined, noAccumulate) + if (e[0] === 'error') { return e } } - const restValidate = /** @type {any} */ (validate(r)) - const e = eachEntry(extra, (_k, v) => restValidate(v), undefined, noAccumulate) - return e[0] === 'error' ? e : ok(value) + return presenceUnchanged(rttiEntries, d[1], value) + ? ok(value) + : verror('unexpected value') } } From 3ddaf268bc0928297ffa7dcdf890d482bdc5ecd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 10:14:17 +0000 Subject: [PATCH 153/370] ci/config: bump pinned tool and dependency versions Bootstrap functionalscript 0.46.1 -> 0.47.0, Deno 2.9.5 -> 2.9.6, and the Nixpkgs nixos-26.05 snapshot to its latest commit (Node versions it provides are unchanged). Bun, Wasmtime, Wasmer, GitHub Action pins, runner images, and the Rust toolchain version checked and already current. Regenerated ci.yml and the Nix flakes via `npm run ci-update`; package.json was untouched so npm/deno/bun lockfiles have no changes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011BcAHCX9aoR4CKUzG25nBT --- .github/workflows/ci.yml | 24 ++++++++++++------------ fjs/ci/config/module.f.mjs | 6 +++--- nix/generated/node22/flake.nix | 2 +- nix/generated/node24/flake.nix | 2 +- nix/generated/node26/flake.nix | 2 +- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 069271364..62a3a58ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ } }, { - "run": "npm install -g functionalscript@0.46.1" + "run": "npm install -g functionalscript@0.47.0" }, { "uses": "actions/checkout@v7.0.1" @@ -81,7 +81,7 @@ } }, { - "run": "npm install -g functionalscript@0.46.1" + "run": "npm install -g functionalscript@0.47.0" }, { "uses": "actions/checkout@v7.0.1" @@ -122,7 +122,7 @@ } }, { - "run": "npm install -g functionalscript@0.46.1" + "run": "npm install -g functionalscript@0.47.0" }, { "uses": "actions/checkout@v7.0.1" @@ -163,7 +163,7 @@ } }, { - "run": "npm install -g functionalscript@0.46.1" + "run": "npm install -g functionalscript@0.47.0" }, { "uses": "actions/checkout@v7.0.1" @@ -205,7 +205,7 @@ } }, { - "run": "npm install -g functionalscript@0.46.1" + "run": "npm install -g functionalscript@0.47.0" }, { "uses": "actions/checkout@v7.0.1" @@ -258,7 +258,7 @@ } }, { - "run": "npm install -g functionalscript@0.46.1" + "run": "npm install -g functionalscript@0.47.0" }, { "uses": "actions/checkout@v7.0.1" @@ -385,17 +385,17 @@ { "uses": "denoland/setup-deno@v2.0.5", "with": { - "deno-version": "2.9.5" + "deno-version": "2.9.6" } }, { - "run": "deno install -g -A --minimum-dependency-age=0 npm:functionalscript@0.46.1" + "run": "deno install -g -A --minimum-dependency-age=0 npm:functionalscript@0.47.0" }, { "uses": "actions/checkout@v7.0.1" }, { - "run": "deno run -A --minimum-dependency-age=0 npm:functionalscript@0.46.1 test" + "run": "deno run -A --minimum-dependency-age=0 npm:functionalscript@0.47.0 test" }, { "run": "deno install --frozen" @@ -415,7 +415,7 @@ } }, { - "run": "bun install -g functionalscript@0.46.1" + "run": "bun install -g functionalscript@0.47.0" }, { "uses": "actions/checkout@v7.0.1" @@ -424,7 +424,7 @@ "run": "bun install --frozen-lockfile" }, { - "run": "bunx functionalscript@0.46.1 test" + "run": "bunx functionalscript@0.47.0 test" }, { "run": "bun test --coverage" @@ -441,7 +441,7 @@ } }, { - "run": "npm install -g functionalscript@0.46.1" + "run": "npm install -g functionalscript@0.47.0" }, { "uses": "actions/checkout@v7.0.1" diff --git a/fjs/ci/config/module.f.mjs b/fjs/ci/config/module.f.mjs index 37739e450..ba7be1fe5 100644 --- a/fjs/ci/config/module.f.mjs +++ b/fjs/ci/config/module.f.mjs @@ -25,13 +25,13 @@ export const images = /** @type {const} */({ // published FunctionalScript release; do not tie it to package.json's current // in-repo version. // https://www.npmjs.com/package/functionalscript -export const functionalscript = /** @type {const} */ '0.46.1' +export const functionalscript = /** @type {const} */ '0.47.0' // https://bun.sh/ export const bun = '1.4.0' // https://deno.com/ -export const deno = '2.9.5' +export const deno = '2.9.6' // The Node versions the pinned Nixpkgs snapshot below provides — read from // `pkgs/development/web/nodejs/v{22,24,26}.nix` at that commit. Every runtime @@ -53,7 +53,7 @@ export const node = /** @type {const} */({ // https://channels.nixos.org/nixos-26.05/git-revision export const nixpkgs = /** @type {const} */({ ref: 'nixos-26.05', - commit: 'f4f698677b11021a8f84f452e23ae9ef2427bec3', + commit: '062346a6d85bc4b49dfaa61c986e9c5be21217d1', }) // https://github.com/bytecodealliance/wasmtime/releases diff --git a/nix/generated/node22/flake.nix b/nix/generated/node22/flake.nix index 38830f402..34f29fc53 100644 --- a/nix/generated/node22/flake.nix +++ b/nix/generated/node22/flake.nix @@ -1,5 +1,5 @@ { - inputs.nixpkgs.url = "github:NixOS/nixpkgs/f4f698677b11021a8f84f452e23ae9ef2427bec3"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/062346a6d85bc4b49dfaa61c986e9c5be21217d1"; outputs = { nixpkgs, ... }: { devShells.aarch64-linux.default = let pkgs = import nixpkgs { diff --git a/nix/generated/node24/flake.nix b/nix/generated/node24/flake.nix index 9661d7d0d..a61542e24 100644 --- a/nix/generated/node24/flake.nix +++ b/nix/generated/node24/flake.nix @@ -1,5 +1,5 @@ { - inputs.nixpkgs.url = "github:NixOS/nixpkgs/f4f698677b11021a8f84f452e23ae9ef2427bec3"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/062346a6d85bc4b49dfaa61c986e9c5be21217d1"; outputs = { nixpkgs, ... }: { devShells.aarch64-linux.default = let pkgs = import nixpkgs { diff --git a/nix/generated/node26/flake.nix b/nix/generated/node26/flake.nix index 08005913d..1ce4acba9 100644 --- a/nix/generated/node26/flake.nix +++ b/nix/generated/node26/flake.nix @@ -1,5 +1,5 @@ { - inputs.nixpkgs.url = "github:NixOS/nixpkgs/f4f698677b11021a8f84f452e23ae9ef2427bec3"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/062346a6d85bc4b49dfaa61c986e9c5be21217d1"; outputs = { nixpkgs, ... }: { devShells.aarch64-linux.default = let pkgs = import nixpkgs { From a60294447521080bde19f52cd797b337521e584a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 10:33:40 +0000 Subject: [PATCH 154/370] web: answer 404 for a path that descends through a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /README.md/` answered `500 io error: ENOTDIR` on POSIX and `404 not found` on Windows. The request is client-caused — a regular file is not a directory, and nothing under it can exist — so it belongs with the `404` answers, and while it was a `500` a trailing slash was a way to ask "is there a regular file at this name?", the enumeration every other identical `404` here exists to deny. Three changes, in the order they depend on each other: - the virtual file system reports `ENOTDIR` for a path descending through a name that is not a directory, so the branch is reachable — and provable — on every host rather than only on POSIX; - `FileStat` grows `isDirectory`, in the Node runner and the virtual one. `isFile === false` is not "is a directory": a FIFO, a device and a socket answer that too; - `respond` maps `ENOTDIR` to `404`, and `main` refuses a root that is not a directory before it binds anything. The mapping re-stats the root before answering, so a root replaced while the server runs goes back to `500` instead of telling every visitor the site is missing for the life of the process. The startup check is what turns the common case — a mistyped root — into immediate feedback. Scoped to `ENOTDIR`: `EACCES` on a mode-000 directory and `ELOOP` on a symlink cycle stay at `500`, both being entries an operator placed. Closes the `notdir-status` todo. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BRNUuhnny5cNz1NFa5KoDV --- fjs/effects/node/module.mjs | 2 +- fjs/effects/node/types.ts | 12 +- fjs/effects/node/virtual/module.f.mjs | 42 ++++- fjs/effects/node/virtual/proof.f.mjs | 41 +++++ fjs/web/README.md | 47 ++++- fjs/web/module.f.mjs | 104 ++++++++++- fjs/web/proof.f.mjs | 71 ++++++++ fjs/web/todo/missing-index-message.md | 35 ++-- fjs/web/todo/name-too-long-status.md | 8 +- fjs/web/todo/notdir-status.md | 248 -------------------------- fjs/web/todo/stat-then-read.md | 6 +- 11 files changed, 329 insertions(+), 287 deletions(-) delete mode 100644 fjs/web/todo/notdir-status.md diff --git a/fjs/effects/node/module.mjs b/fjs/effects/node/module.mjs index b523f9b3f..9bd740c1a 100644 --- a/fjs/effects/node/module.mjs +++ b/fjs/effects/node/module.mjs @@ -425,7 +425,7 @@ const runNodeEffect = asyncRun({ }), stat: path => io(async () => { const s = await stat(path) - return { size: s.size, isFile: s.isFile() } + return { size: s.size, isFile: s.isFile(), isDirectory: s.isDirectory() } }), import: path => io(() => asyncImport(path)), exec: (command, stdin) => new Promise(resolve => { diff --git a/fjs/effects/node/types.ts b/fjs/effects/node/types.ts index 886a045f3..946b2d393 100644 --- a/fjs/effects/node/types.ts +++ b/fjs/effects/node/types.ts @@ -182,8 +182,8 @@ export type _WriteLoop = (offset: number, e: List(offset: number, e: List IoResult] diff --git a/fjs/effects/node/virtual/module.f.mjs b/fjs/effects/node/virtual/module.f.mjs index d60e310f1..496d20596 100644 --- a/fjs/effects/node/virtual/module.f.mjs +++ b/fjs/effects/node/virtual/module.f.mjs @@ -121,6 +121,10 @@ const mkdir = recursive => operation(mkdirOp(recursive)) /** Absent-path error mirroring Node's `ENOENT`, so `isNotFound` recognizes it. */ const enoent = error(ioError({ code: 'ENOENT', message: 'no such file or directory' })) +/** What a POSIX host answers for a path that descends through a name which is + * not a directory — see {@link statPath}, its only source here. */ +const enotdir = error(ioError({ code: 'ENOTDIR', message: 'not a directory' })) + /** @type {(path: string) => (state: State) => readonly [State, IoResult]} */ const readFile = readOperation((dir, path) => { if (path.length !== 1) { return enoent } @@ -326,11 +330,19 @@ const readBytesOp = (path, offset, size) => readOperation((dir, p) => { return ok(result) })(path) -/** What `stat` answers for a name that exists and is not a regular file. +/** What `stat` answers for a name that exists and is neither a regular file nor + * a directory — this file system's `JsModule`, standing in for a host's FIFO, + * device or socket. * * @type {IoResult} */ -const notRegular = ok({ size: 0, isFile: false }) +const notRegular = ok({ size: 0, isFile: false, isDirectory: false }) + +/** What `stat` answers for a directory. + * + * @type {IoResult} + */ +const directory = ok({ size: 0, isFile: false, isDirectory: true }) /** Total byte size of a chunk-list file (each chunk is byte-aligned). * @@ -383,23 +395,35 @@ const writeBytesOp = (path, offset, data) => operation(writeBytesRawOp(offset, d * file, and a caller's guard against reading one can only be exercised here if * this runner does the same. * - * Two entries answer `isFile: false`. A `JsModule` is this file system's stand-in - * for a name that exists and is not a file at all. A **directory** arrives as an + * Two entries answer `isFile: false`, and they are *not* the same answer. A + * `JsModule` is this file system's stand-in for a name that exists and is not a + * file at all, so both flags are false for it. A **directory** arrives as an * empty remaining path, because `operation` has already descended into it — the - * one way to reach `statOp` with nothing left to look up — and that is what it - * means, root included. + * one way to reach `statOp` with nothing left to look up — and answers + * `isDirectory: true`, root included. + * + * **A path that descends through a non-directory is `ENOTDIR`, not `ENOENT`.** + * `operation` stops descending at the first entry that is not a `Dir`, so more + * than one segment left over means the name before them exists and has nothing + * under it — which is what a POSIX host says with `ENOTDIR` where it says + * `ENOENT` for a name that is simply absent. Answering `ENOENT` for both made + * `stat('README.md/index.html')` indistinguishable from `stat('nope/index.html')` + * here while a host distinguishes them, so a caller's `ENOTDIR` branch could not + * be reached — let alone proven — against this runner. (Windows reports `ENOENT` + * for it, so this models POSIX; a caller that treats the two alike is right on + * both.) * * @type {(path: string) => (state: State) => readonly [State, IoResult]} */ const statPath = readOperation((dir, path) => { - if (path.length === 0) { return notRegular } - if (path.length !== 1) { return enoent } + if (path.length === 0) { return directory } const file = dir[path[0]] if (file === undefined) { return enoent } + if (path.length !== 1) { return enotdir } // `isBinFile` rather than a local `Array.isArray`: which entity kind a name // holds is asked in one place now (#1697), and `stat` is one of its askers. if (!isBinFile(file)) { return notRegular } - return ok({ size: fileSizeBytes(file), isFile: true }) + return ok({ size: fileSizeBytes(file), isFile: true, isDirectory: false }) }) /** diff --git a/fjs/effects/node/virtual/proof.f.mjs b/fjs/effects/node/virtual/proof.f.mjs index 2ac957e55..7695b3dc0 100644 --- a/fjs/effects/node/virtual/proof.f.mjs +++ b/fjs/effects/node/virtual/proof.f.mjs @@ -428,6 +428,9 @@ export const proof = { const [, result] = virtual({ ...emptyState, root })(stat('docs')) assert(result[0] === 'ok', result) assertEq(result[1].isFile, false) + // And says *what* it is, which `!isFile` cannot: a `JsModule` below + // answers false to both. + assertEq(result[1].isDirectory, true) }, statOnEmptyPath: () => { // An empty path is not the root, though `parse` collapses both to no @@ -439,6 +442,7 @@ export const proof = { const [, root] = virtual(emptyState)(stat('.')) assert(root[0] === 'ok', root) assertEq(root[1].isFile, false) + assertEq(root[1].isDirectory, true) }, statOnJsModule: () => { // A `JsModule` entry is this file system's non-regular name: it exists @@ -450,8 +454,45 @@ export const proof = { const [, result] = virtual({ ...emptyState, root })(stat('a.f.ts')) assert(result[0] === 'ok', result) assertEq(result[1].isFile, false) + // Neither a file nor a directory. That is why `isDirectory` is its own + // flag: a caller asking `!isFile` for "may I descend into it" would + // descend into a FIFO. + assertEq(result[1].isDirectory, false) assertEq(result[1].size, 0) }, + statOnRegularFile: () => { + /** @type {Dir} */ + const root = { 'a.txt': [vec8(0x41n)] } + const [, result] = virtual({ ...emptyState, root })(stat('a.txt')) + assert(result[0] === 'ok', result) + assertEq(result[1].isFile, true) + assertEq(result[1].isDirectory, false) + assertEq(result[1].size, 1) + }, + statThroughNonDirectory: () => { + // A path that descends through a name which is not a directory is + // `ENOTDIR` — the name exists and has nothing under it — where a path + // whose *first* missing segment is simply absent stays `ENOENT`. A POSIX + // host draws the same line, and a caller that maps one of the two to its + // own answer cannot be proven against a runner that reports both alike. + /** @type {Dir} */ + const root = { 'a.txt': [vec8(0x41n)], 'm.f.ts': () => ({}), docs: {} } + /** @type {(path: string) => string | undefined} */ + const code = path => { + const [, result] = virtual({ ...emptyState, root })(stat(path)) + assert(result[0] === 'error', result) + assert(result[1][0] === 'ioError', result[1]) + return result[1][1].code + } + assertEq(code('a.txt/index.html'), 'ENOTDIR') + // Any depth below it, and a `JsModule` is no more descendable. + assertEq(code('a.txt/x/y'), 'ENOTDIR') + assertEq(code('m.f.ts/index.html'), 'ENOTDIR') + // Absent names stay `ENOENT`, whether the missing segment is the last + // one or the one being descended through. + assertEq(code('nope.txt/index.html'), 'ENOENT') + assertEq(code('docs/nope.html'), 'ENOENT') + }, largeFileReadBytes: () => { // A file stored as two 128 KiB chunks is larger than maxLengthBytes. // readBytes within the second chunk (offset = 128 KiB, size = 1) should succeed. diff --git a/fjs/web/README.md b/fjs/web/README.md index 51b9bbdd9..1a5f5404c 100644 --- a/fjs/web/README.md +++ b/fjs/web/README.md @@ -98,7 +98,7 @@ relative. That is also why the path is built with `join` rather than `concat`. | case | status | |---|---| | file found | `200` with its bytes | -| `GET`/`HEAD` on a missing, dot-prefixed, or non-regular path | `404` | +| `GET`/`HEAD` on a missing, dot-prefixed, or non-regular path, or one descending through a file | `404` | | any other method | `405`, with `Allow: GET, HEAD` | | a `Host` this server does not answer for | `403` | | a path that escapes `root`, or an undecodable URL | `400` | @@ -140,6 +140,51 @@ thread-pool slot while it waited. A served tree with one FIFO in it and a handfu of requests would stall every other response. Size cannot stand in for the check: a FIFO stats as zero bytes and passes every bound. +### A path that descends through a file + +`/README.md/` asks for `README.md/index.html`, and a POSIX host answers that +`stat` with `ENOTDIR`: the name before the slash exists and has nothing under +it. That is client-caused in the way a missing name is — every served tree has +thousands of regular files, so any client can ask — so it is a `404`, the same +answer `/nope.md/` gets. + +While it was a `500` the two answered differently, which made a trailing slash a +way to ask *"is there a regular file at this name?"* — the enumeration the +identical `404`s elsewhere exist to deny. It was also platform-dependent: +Windows reports `ENOENT` for the same request and answered `404` already, so one +request had two statuses depending on the host it ran on. + +**Only `ENOTDIR`.** A directory whose mode denies traversal (`EACCES`) and a +symlink cycle (`ELOOP`) reach the same directory-form shape on POSIX and stay at +`500`: both are entries an operator placed, and a `500` saying the host could not +read what it was pointed at is not obviously the wrong answer for them. `EISDIR` +needs no rule — `stat` succeeds on a directory, `isFile` is false, and it is +already `404`. + +**And only while the root is a directory.** `fjs web README.md` would make every +request stat a path descending through a file, and mapping that to `404` would +tell every visitor the site is missing and the operator nothing at all. So the +root is checked twice over: `main` refuses a root that is not a directory before +it binds anything — reported on `stderr` with exit code `1`, like a bad port — +and the `ENOTDIR` mapping re-stats the root before answering, so a root +*replaced* while the server runs goes back to `500`. The re-check costs a `stat` +on the `ENOTDIR` path and nothing on any other, and what it leaves is the +request-local window [stat-then-read](./todo/stat-then-read.md) already +describes, rather than a wrong status for the life of the process. + +A root that is *deleted* rather than replaced is not covered: every later `stat` +fails `ENOENT`, which is the ordinary `404` path, and validating the root before +accepting an `ENOENT` too would put a second `stat` on the most common answer a +static server gives to improve a diagnostic. `404` is not false in either case — +with the root gone or a file, nothing under it exists — so what the asymmetry +costs is diagnostic reach, not correctness. The version that answers both is +holding the root **open** and resolving beneath the handle, which is +[stat-then-read](./todo/stat-then-read.md)'s effect. + +`FileStat` grew `isDirectory` for the startup check: `isFile === false` is not +"is a directory", since a FIFO, a device and a socket answer that too, and +serving one of those as a root is the same mistake as serving a file. + ### The size limit `readFile` yields a single `Vec`, which caps at 131,072 bytes, and diff --git a/fjs/web/module.f.mjs b/fjs/web/module.f.mjs index 2e9c28a07..cc4642695 100644 --- a/fjs/web/module.f.mjs +++ b/fjs/web/module.f.mjs @@ -14,7 +14,7 @@ * | case | status | * |-----------------------------------------------|--------| * | file found | `200` | - * | `GET`/`HEAD` on a missing, dot-prefixed, or non-regular path | `404` | + * | `GET`/`HEAD` on a missing, dot-prefixed, or non-regular path, or one descending through a file | `404` | * | any other method | `405` with `Allow` | * | a `Host` this server does not answer for | `403` | * | a path that escapes `root`, or an undecodable URL | `400` | @@ -31,17 +31,17 @@ * @module * * @import { Effect } from '../effects/types.ts' - * @import { FileStat, IoChannel, Program, ReadFile, ServerResponse } from '../effects/node/types.ts' + * @import { FileStat, IoChannel, Program, ReadFile, ServerResponse, Stat } from '../effects/node/types.ts' * @import { Nullable } from '../types/nullable/types.ts' * @import { Result } from '../types/result/types.ts' * @import { Vec } from '../types/bit_vec/types.ts' * @import { Refusal, Resolve, Respond, WebOp } from './types.ts' */ -import { pureError, pureOk, resultMapStep, step } from '../effects/module.f.mjs' +import { pureError, pureOk, resultMapStep, resultStep, step } from '../effects/module.f.mjs' import { - createServer, errorExit, errorSummary, exitStep, forever, isNotFound, listen, log, readFile, - stat, + createServer, errorExit, errorMessage, errorSummary, exitStep, forever, isNotFound, listen, log, + readFile, stat, } from '../effects/node/module.f.mjs' import { detectPath } from '../media/type/module.f.mjs' import { escapes, join, parse } from '../path/module.f.mjs' @@ -498,6 +498,64 @@ const fileResponse = path => r => { return plainText(500)(errorSummary(e)) } +/** + * What a POSIX host reports for a path that descends through a name which is + * not a directory — `GET /README.md/`, whose `index.html` is under a regular + * file. Windows reports `ENOENT` for the same request, so this is the one + * request whose status differed by host. + * + * @type {string} + */ +const notDirectory = 'ENOTDIR' + +/** + * Whether `s` describes a directory this server can serve from. A `stat` that + * failed describes nothing, and `isFile === false` is not the question: + * a FIFO, a device and a socket answer that too. + * + * @type {(s: Result) => boolean} + */ +const isServableRoot = s => s[0] === 'ok' && s[1].isDirectory + +/** + * The response frame for whatever reading `path` produced — {@link fileResponse} + * for every case but one, and that one is why this is an effect rather than a + * function. + * + * **`ENOTDIR` is `404`.** A path that descends through a regular file names + * nothing, which is client-caused in exactly the way a missing name is, so it + * belongs with the `404` answers rather than in the channel reserved for the + * host failing at something it should have managed. It is also a disclosure + * while it is a `500`: `/README.md/` and `/nope.md/` answer differently, which + * is precisely the enumeration the identical `404`s elsewhere are written to + * deny. + * + * **Unless the root itself is the non-directory**, which is why the answer + * cannot be read off the error alone. `fjs web README.md` makes *every* request + * stat a path descending through a file, and answering `404` to all of them + * would tell a visitor the file is missing and the operator nothing at all. + * {@link main} refuses such a root at startup, but a root replaced while the + * server runs would otherwise turn one operator mistake into a lie told to + * every visitor for the life of the process — so the root is re-checked here, + * and only a root that is still a directory earns the `404`. + * + * The re-check costs a `stat` on the `ENOTDIR` path and nothing on any other, + * and what it leaves is the request-local race + * [stat-then-read](./todo/stat-then-read.md) already describes: a wrong status + * in a vanishing window rather than a wrong status forever. + * + * @type {(root: string) => (path: string) => (r: Result) => Effect} + */ +const answer = root => path => r => { + const hostAnswer = fileResponse(path)(r) + /** @type {Effect} */ + const framed = r[0] === 'error' && r[1][0] === 'ioError' && r[1][1].code === notDirectory + ? resultMapStep(stat(served(root)), s => + ok(isServableRoot(s) ? plainText(404)('not found') : hostAnswer)) + : pureOk(hostAnswer) + return framed +} + /** * Answers one request: resolve, read, and frame the result. * @@ -531,7 +589,10 @@ export const respond = root => ({ method, url, headers }) => { } const path = resolved[1] const bytes = step(stat(path), readBounded(path)) - return resultMapStep(bytes, r => ok(fileResponse(path)(r))) + // `resultStep`, not `resultMapStep`: framing the result is pure for every + // case but `ENOTDIR`, which asks the file system one more question — see + // {@link answer}. + return resultStep(bytes, answer(root)(path)) } // ── The program ─────────────────────────────────────────────────────────────── @@ -557,6 +618,18 @@ const loopback = '127.0.0.1' * option yet; `port` becomes `--port` once one exists, and `--host` is what * would let a caller bind anything but loopback. * + * **The root is checked before the socket is.** A root that is not a directory + * — a mistyped name, or `fjs web README.md` — is a command-line mistake, and it + * is reported like the port's: on `stderr`, with exit code `1`, at the moment it + * was made rather than on some visitor's request. Without it the mistake is + * silent until then, and differently silent per host: a POSIX `stat` under such + * a root fails `ENOTDIR` and answered `500` to everything, while Windows reports + * `ENOENT` and answered `404` to everything. + * + * It stats {@link served}`(root)`, never the argument as written: `fjs web ''` + * is a supported invocation naming the working directory, and `stat('')` fails + * `ENOENT` on every host. + * * The chain ends in `forever`, so the program only stops when the process does. * A runner that cannot block forever answers `notImplemented` there, which is * how the whole program remains runnable — and observable — under the virtual @@ -573,9 +646,24 @@ export const main = ({ args }) => { if (!Number.isInteger(port) || port < 1 || port > maxPort) { return errorExit(`invalid port "${portArgument}"`) } + const base = served(root) const server = createServer(respond(root)) const listening = step(server, s => listen(s, port, loopback)) - const announced = step(listening, () => log(`serving ${served(root)} on http://${loopback}:${port}/`)) + const announced = step(listening, () => log(`serving ${base} on http://${loopback}:${port}/`)) const ended = step(announced, forever) - return exitStep(ended) + return resultStep(stat(base), s => { + // Bound rather than returned inline, for the reason `exitStep` binds + // its own: the branches are two different `Effect`s and `step` would + // infer neither from the union. + // + // `errorMessage`, not `errorSummary`: this line is for the operator who + // typed the argument, and the host's own words name what it could not + // stat. A client is the one that is not entitled to that. + const reason = s[0] === 'error' ? errorMessage(s[1]) : 'not a directory' + /** @type {Effect} */ + const program = isServableRoot(s) + ? exitStep(ended) + : errorExit(`invalid root "${base}": ${reason}`) + return program + }) } diff --git a/fjs/web/proof.f.mjs b/fjs/web/proof.f.mjs index c7e87f1c7..f531896ee 100644 --- a/fjs/web/proof.f.mjs +++ b/fjs/web/proof.f.mjs @@ -315,6 +315,41 @@ export const proof = { assertEq(r.status, 404) assertEq(body(r), 'not found\n') }, + // A path that descends through a regular file names nothing, so it is + // answered exactly like a path that descends through nothing. While it + // was a `500` the pair answered differently, which made a trailing + // slash a way to ask "is there a file at this name?" — the enumeration + // every other identical `404` here exists to deny. + // + // Proven through the virtual file system rather than the host's: the + // status differed by platform (POSIX `ENOTDIR`, Windows `ENOENT`), so a + // proof reading the real `stat` would cover this branch on one host and + // not the other. + throughFile: () => { + const throughRegular = answerSite('GET', '/main.css/') + const throughNothing = answerSite('GET', '/nope.md/') + assertEq(throughRegular.status, throughNothing.status) + assertEq(body(throughRegular), body(throughNothing)) + assertEq(throughRegular.status, 404) + assertEq(body(throughRegular), 'not found\n') + // At any depth, and for an entry that is not a regular file either. + assertEq(answerSite('GET', '/main.css/a/b.txt').status, 404) + assertEq(answer({ 'pipe.txt': () => ({}) })('GET', '/pipe.txt/x').status, 404) + // It says nothing about the other directory-form failures: a + // permission-denied or looping entry is one an operator placed, and + // stays a `500` (see `./todo/`). + }, + // …but only while the root is still a directory. Replace it with a file + // and every request descends through one, so the `404` above would + // report the operator's mistake as the client's — permanently, and to + // everyone. `main` refuses such a root at startup; this is what keeps + // the answer true if it is replaced afterwards. + rootNotDirectory: () => { + const r = unwrap(virtual({ ...emptyState, root: site })( + respond('main.css')(request('GET', '/')))[1]) + assertEq(r.status, 500) + assertEq(body(r), 'io error: ENOTDIR\n') + }, // A host failure that is not a missing path is not a 404. A runner that // cannot `stat` at all is the sharpest case: nothing looked for the // file, so answering "not found" would be a claim nobody checked. @@ -375,6 +410,42 @@ export const proof = { assertEq(s.stdout, 'serving site on http://127.0.0.1:9090/\n') assertEq(s.responses[0].status, 200) }, + // A root that is not a directory is the same kind of mistake as a port + // that is not a port, and is reported the same way — at the moment it + // was made, rather than as a status code some visitor gets later. Both + // failures reach `errorExit`: `stat` refusing the name at all, and a + // name that exists and is not a directory. + badRoot: () => { + /** A site with one entry that is neither a file nor a directory. + * + * @type {Dir} + */ + const root = { ...site, 'pipe.txt': () => ({}) } + /** @type {(argument: string, reason: string) => void} */ + const rejects = (argument, reason) => { + const options = { ...defaultNodeProgramOptions, args: [argument] } + const [s, result] = virtual({ ...emptyState, root })(main(options)) + assertEq(exitCode(result), 1) + assertEq(s.stderr, `invalid root "${argument}": ${reason}\n`) + // Nothing was bound, and nothing announced: the root is checked + // before the socket exists. + assertEq(s.listening.length, 0) + assertEq(s.stdout, '') + } + // `fjs web README.md` — a regular file is not a root, though every + // path under it would have looked merely missing. + rejects('main.css', 'not a directory') + // A name that is not there at all. The operator's own words are + // forwarded here, unlike in a response, where the host's message + // would publish the server's filesystem layout. + rejects('nope', 'no such file or directory') + // An entry that is neither: this file system's `JsModule` stands in + // for a FIFO, a device or a socket, none of which can be served — + // and none of which an `isFile` test alone tells from a directory, + // which is why `FileStat` grew `isDirectory` rather than this + // asking `!isFile`. + rejects('pipe.txt', 'not a directory') + }, // A port that is not a port is a command-line mistake, not a defect: // reported on `stderr` with exit code 1, like every other `fjs` command. badPort: () => { diff --git a/fjs/web/todo/missing-index-message.md b/fjs/web/todo/missing-index-message.md index 8eed5bd78..4a6833a48 100644 --- a/fjs/web/todo/missing-index-message.md +++ b/fjs/web/todo/missing-index-message.md @@ -24,19 +24,22 @@ The same sentence covers every other refusal: | `/no-such-dir/` | `404 not found` | | `/fjs/web/nope.md` | `404 not found` | | `/.git/` | `404 not found` | -| `/README.md/` — a trailing slash onto a regular file | `500 io error: ENOTDIR` on POSIX, `404 not found` on Windows | +| `/README.md/` — a trailing slash onto a regular file | `404 not found` | -Byte-identical, the first five. That is not an oversight for `/.git/`: refusing +Byte-identical, all six. That is not an oversight for `/.git/`: refusing a dot-prefixed path as *absent* is what keeps its existence undisclosed, per `resolve`'s note and "Deliberately absent" in [`../README.md`](../README.md). So the fix is not "say more when a file is missing" — it is to say more about **what was asked for** without saying more about what is on disk. -The last row is the exception, and it sits in exactly the shape this issue -triggers on: a directory-form request whose status already tells an existing -regular file from nothing. It is filed separately as -[notdir-status](./notdir-status.md), because the message is not what is wrong -with it. +The last row was the exception until `ENOTDIR` became a `404` — it answered +`500 io error: ENOTDIR` on POSIX and `404 not found` on Windows, so a directory- +form request's status told an existing regular file from nothing. That shipped +as `answer` in [`../module.f.mjs`](../module.f.mjs), with the reasoning under +"A path that descends through a file" in [`../README.md`](../README.md). Two +directory-form requests still disclose on POSIX and are scoped out there +deliberately: a mode-`000` directory (`EACCES`) and a symlink cycle (`ELOOP`) +stay at `500`. ### Proposal @@ -175,10 +178,10 @@ re-encoded on the way out — so that choice stays open on its own merits. `proof.f.mjs` already does, since that path has no HTTP parser in front of it. - [ ] Prove that `/fjs/` and `/no-such-dir/` still answer identically — and - `/README.md/` with them, which needs - [notdir-status](./notdir-status.md) first. Without it the proof passes - while the directory-form shape still leaks. It will pass anyway for - `/locked/` and `/loop1/`, which that issue scopes out on purpose, so + `/README.md/` with them, which `answer`'s `ENOTDIR` mapping in + [`../module.f.mjs`](../module.f.mjs) now makes true (`throughFile` in + [`../proof.f.mjs`](../proof.f.mjs) pins that pair). It will pass anyway + for `/locked/` and `/loop1/`, which that change scopes out on purpose, so state what the proof covers rather than letting it read as "no directory-form request discloses". - [ ] Update the response table in `module.f.mjs` and the prose in @@ -188,10 +191,10 @@ re-encoded on the way out — so that choice stays open on its own merits. - [`fjs/web`](../README.md) — "Deliberately absent", where the missing directory listing and the `/docs` vs `/docs/` split are settled. -- [notdir-status](./notdir-status.md) — a directory-form request whose status - already discloses, which this issue's proof depends on. Not the only one: it - scopes itself to `ENOTDIR` and leaves `EACCES` and `ELOOP` at `500` - deliberately, so directory-form requests still do not answer uniformly on a - POSIX host. +- [`../README.md`](../README.md), "A path that descends through a file" — the + directory-form request whose status used to disclose, and which this issue's + proof depended on. Fixed for `ENOTDIR` only: `EACCES` and `ELOOP` stay at + `500` deliberately, so directory-form requests still do not answer uniformly + on a POSIX host. - [`fjs/website`](../../website/) — writes the `index.html` whose absence this is about. diff --git a/fjs/web/todo/name-too-long-status.md b/fjs/web/todo/name-too-long-status.md index aeb265298..7bffad5c8 100644 --- a/fjs/web/todo/name-too-long-status.md +++ b/fjs/web/todo/name-too-long-status.md @@ -40,6 +40,12 @@ too-long name starts failing — and belongs in its own pull request. ### Related -- [`fjs/web`](../README.md) — the response table, where `500` currently covers it. +- [`fjs/web`](../README.md) — the response table, where `500` currently covers it, + and "A path that descends through a file", where the same shape was already + answered for `ENOTDIR`: the virtual file system grew the error first, then + `answer` in [`../module.f.mjs`](../module.f.mjs) mapped it. This one is the + simpler half of that — no disclosure to close and no platform split — so it + follows the same two steps and needs no root re-check, since a too-long name + says nothing about the root. - `fjs/effects/node/virtual/module.f.mjs` — the file system that would grow the limit. diff --git a/fjs/web/todo/notdir-status.md b/fjs/web/todo/notdir-status.md deleted file mode 100644 index 3177a5d75..000000000 --- a/fjs/web/todo/notdir-status.md +++ /dev/null @@ -1,248 +0,0 @@ -## notdir-status. A path through a regular file answers `500`, and only on POSIX - -**Priority:** P3 -**Status:** open - -### Problem - -`GET /README.md/` answers `500 io error: ENOTDIR` on Linux and macOS. The -request is client-caused — a regular file is not a directory, and nothing under -it can exist — so by this module's own doctrine it belongs with the `404` -answers rather than in the channel reserved for the host failing at something it -should have managed. The same argument is already made about `%00`: *"a NUL is a -malformed URL, not a host error"*, and again in -[name-too-long-status](./name-too-long-status.md) for `ENAMETOOLONG`. - -`isNotFound` (`fjs/effects/node/module.f.mjs`) tests `ENOENT` and nothing else, -so `fileResponse` falls past its `404` branch to the `500`. - -**It is also a disclosure, which `ENAMETOOLONG` is not.** The status separates -a path that runs through an existing regular file from one that runs through -nothing: - -| request | POSIX | Windows | -|---|---|---| -| `/nope.md/` — nothing there | `404 not found` | `404 not found` | -| `/README.md/` — an existing regular file | `500 io error: ENOTDIR` | `404 not found` | - -So on POSIX a trailing slash answers "is there a regular file at this name?", -which is the enumeration the identical-`404` answers elsewhere are written to -deny — see the dot-prefixed-existence note in `resolve` and "Deliberately -absent" in [`../README.md`](../README.md). - -**And the status is platform-dependent**, which is the part that makes it worth -filing beyond its sibling. Windows returns `ENOENT` where POSIX returns -`ENOTDIR`, so the same request is `404` on one host and `500` on another, and a -proof written on one platform cannot see the other's answer. Verified directly: -`statSync('README.md/index.html')` reports `ENOENT` on `win32`. - -Reported on -[#1714](https://github.com/functionalscript/functionalscript/pull/1714), where -it contradicted a claim that the `404` was uniform. - -### Proposal - -Answer `404`: a path that descends through a regular file names nothing, and -whether the file it descends through exists is not a distinction worth -publishing. - -**Test it in `fileResponse`, not in `isNotFound`.** Widening the shared -predicate is the tempting reading — `ENOENT` and `ENOTDIR` are one fact -wearing two names, and which one a host says is not a distinction this module -wants. But `isNotFound` has two other callers, and both document, in prose, an -intent that widening would violate: - -- `fjs/cas`'s `list` answers `ok([])` for an absent store and surfaces - everything else, because *"a `.cas` that exists but cannot be read - (permissions, corruption) is a genuine storage error and is surfaced, not - masked as 'no hashes'"*. A store path with a regular file among its - components would start reporting as an **empty store**. -- `fjs/cas/evo`'s `decodeReadRevision` splits `revision not found` from - `failed to read revision`, because *"calling any of those 'not found' would - deny a stored revision exists"*. It would start denying one. - -Both would fail quietly, in the direction that loses data rather than the one -that raises an error, and neither is a trade this issue is entitled to make on -their behalf. A predicate named for one errno is the wrong place to put a -second one that only some of its callers want. - -So the answer is local: this is the same kind of test as `fileResponse`'s -existing `notRegular` and `tooLarge` cases — what *this server* will answer as -absent. If a later caller wants the same reading, the thing to share is a -named predicate that says so, not a broader `isNotFound`. - -**The branch itself goes in `respond`, not in `fileResponse`.** `fileResponse` -is `(path) => (Result) => ServerResponse`: pure, and holding neither `root` -nor any way to run a `stat`, so the re-check below cannot live there. `respond` -has both, and already ends in `resultMapStep(bytes, r => ok(fileResponse(path)(r)))` -— where `resultMapStep` is by definition `resultStep` over a *pure* function. -Dropping to `resultStep` is the whole change: the `ENOTDIR` case becomes a -`step(stat(served(root)), …)` deciding `404` or `500`, every other case stays -`fileResponse(path)(r)` as today, and `fileResponse` keeps its signature. - -**But validate the root first, or the mapping swallows an operator error.** -`ENOTDIR` does not only arise below a valid root. `fjs web README.md` serves a -regular file as its root, so `join` produces `README.md/index.html` and *every* -request stats a path descending through a file — the same errno, and nothing -inside `fileResponse` can tell it from `/README.md/` under a good root. A -blanket mapping would answer `404 not found` to every request against a -misconfigured server, which is the one case where `500` was telling the -operator something true. - -So the root is checked in `main`, before `listen`: if it is not a directory, -`errorExit` the way an out-of-range port already does. **Both checks stat -`served(root)`, never the argument as written** — `served` maps `''` to `.`, -and `fjs web ''` is a supported invocation with proofs of its own -(`emptyRoot`, twice). Statting the raw argument would make `stat('')` fail -`ENOENT` and reject it at startup, and would misjudge the re-check below under -`respond('')`. That is better than a -per-request comparison of the offending component against the root — it needs -no extra `stat` on the serving path, and it fails at the moment the mistake -was made rather than on someone else's request. - -**But a startup check alone does not establish the invariant the mapping -needs.** Rename the root, or replace it with a regular file, and every later -`stat(root/…)` is `ENOTDIR` from the root itself — which `fileResponse` would -then report as a client-caused `404` for the rest of the process's life. That -is not the request-local window -[stat-then-read](./stat-then-read.md) describes, where two calls race -microseconds apart; this one opens once and stays open, and it turns the -operator's mistake into a lie told to every visitor. An earlier draft of this -file claimed the two windows were the same size. They are not. - -So the mapping re-checks: on `ENOTDIR`, `stat` `served(root)`, and answer -`404` only if it is still a directory — otherwise `500`, which is again the -true answer. -The cost sits where it belongs, since `ENOTDIR` is the rare path and the -serving path is untouched. What remains is a genuine race, between that -re-check and the `stat` that produced the error, and it is the request-local -kind that `stat-then-read` already covers — a wrong status in a vanishing -window rather than a wrong status forever. - -Keep the startup check as well. It is what turns the common case — a mistyped -root — into immediate feedback instead of a `500` that waits for a visitor. - -**A root that is deleted rather than replaced is left as it is, and that is a -cost decision.** Renaming or removing the root makes every later `stat` fail -`ENOENT`, not `ENOTDIR`, so it takes the existing `isNotFound` branch and -answers `404` — the same permanent operator failure reported as client-caused -absence, and no re-check catches it. The symmetric fix would be to validate -the root before accepting any `ENOENT` too, and that is declined here: an -`ENOENT` `404` is the most common answer a static server gives, so this would -put a second `stat` on the hot path to improve a diagnostic, where the -`ENOTDIR` re-check pays nothing on it. That is a trade rather than a -principle, and it should be stated as one. - -Note also that `404` is not *false* in either case — with the root gone or a -file, nothing under it exists. What the `500` buys is telling the operator -which mistake they made, so what is lost by the asymmetry is diagnostic reach, -not correctness. - -The version that answers both, and needs no re-check at all, is holding the -root **open** and resolving beneath the handle, so it cannot be swapped -underneath the server at any point. That is the effect -[stat-then-read](./stat-then-read.md) is already blocked on, and this is a -second reason to want it. - -Worth noticing that this is already the answer on Windows, silently: `stat` -there reports `ENOENT`, so `fjs web README.md` starts happily and answers -`404` to everything — verified. The check makes both hosts say the same true -thing at startup instead of two different misleading things per request. - -**The check needs an operation the effect layer does not have.** `FileStat` is -`{ size, isFile }`, so `isFile === false` is not "is a directory" — it also -covers a FIFO, a device, a socket, and the virtual runner's `JsModule`, whose -`_Entity` is `readonly Vec[] | Dir | JsModule`. Serving any of those as a root -is the same operator error as serving a regular file, so the check has to name -what it wants: **add `isDirectory` to `FileStat`**, in the node runner and the -virtual one together, and reject a root that is not one — including the case -where both flags are false. - -Not `readdir(root)`, which needs no new API and is the obvious alternative. It -answers a different question: a directory may be traversable without being -listable — mode `--x` permits opening a known path under it while `readdir` -fails `EACCES` — so a root that this server can serve perfectly well would be -refused at startup. Reading a whole directory to discard it is the smaller -objection. - -Failure handling is the same `errorExit` either way, and covers two cases: -`stat` failing at all — a root that does not exist — and a root that exists -and is not a directory. Both are the command line being wrong, which is what -`main` already reports that way for a port. - -**Scope: `ENOTDIR` only, and the other two stay at `500` deliberately.** Two -more `stat` failures reach the same directory-form shape and disclose the same -way, on POSIX: - -| request | POSIX | -|---|---| -| `/locked/` — a directory with an `index.html`, mode `000` | `500 io error: EACCES` | -| `/loop1/` — a symlink cycle | `500 io error: ELOOP` | - -Both are left as they are, because the doctrine that makes `ENOTDIR` a `404` -does not reach them. `ENOTDIR` fires on any ordinary file — every served tree -has thousands, so any client can ask — which is what makes it client-caused. A -mode-`000` directory or a symlink cycle is an entry an operator placed, and a -`500` saying the host could not read what it was pointed at is not obviously -the wrong answer. Reopen them on their own evidence, not as a corollary of -this. - -`EISDIR` needs no entry: `stat` succeeds on a directory and `isFile` is false, -so it is already `notRegular` → `404`. - -**All three are POSIX-only.** Windows has none of them — `ENOTDIR` arrives as -`ENOENT` (see the table above), mode `000` does not stop traversal, so -`stat('locked/index.html')` simply succeeds, and a symlink cycle reports -`ENOENT` rather than `ELOOP`. So the oracle is a property of POSIX hosts, and -a proof of its absence has to run on one. - -The obstacle is the same as its sibling's: the virtual file system never -reports `ENOTDIR`, so the branch would be unreachable, which the coverage gate -rejects and `fjs/AGENTS.md` §1.2 says to restructure away rather than leave -uncovered. So this is two changes — teach the virtual file system to refuse a -path that descends through a regular file, then map the error. - -### Tasks - -- [ ] Report `ENOTDIR` from the virtual file system for a path descending - through a regular file. -- [ ] Add `isDirectory` to `FileStat`, in the node runner and the virtual one. -- [ ] Reject a non-directory root in `main`, before `listen` — including a - root that does not exist, and one that is neither file nor directory. - Stat `served(root)`, so `fjs web ''` keeps working; `emptyRoot` in - `proof.f.mjs` pins it. -- [ ] Answer `404` for it from `respond` — `resultStep` in place of - `resultMapStep`, leaving `fileResponse` and `isNotFound` alone — and - only after re-checking that the root is still a directory, so a root - replaced after startup keeps answering `500`. -- [ ] Prove `/README.md/` and `/nope.md/` answer identically, through the - virtual runner — `proof.f.mjs` already drives `respond` that way, so - once the virtual file system reports `ENOTDIR` the proof runs anywhere - and covers the branch on every host. It must not be conditioned on the - host's `stat`, which would leave the new branch uncovered on Windows. - The proof covers `ENOTDIR` and says so: `/locked/` and `/loop1/` stay at - `500` by the scoping above, so it must not claim directory-form requests - disclose nothing in general. -- [ ] Check the real answer on a POSIX host once, separately — the virtual - file system models what the host does, and this is the issue where that - model was wrong on two platforms at once. -- [ ] Update the response table in `module.f.mjs` and - [`../README.md`](../README.md). - -### Related - -- [name-too-long-status](./name-too-long-status.md) — the same shape for - `ENAMETOOLONG`, without the disclosure or the platform split. -- [missing-index-message](./missing-index-message.md) — triggers on the - directory-form request this leaks through. -- `fjs/effects/node/module.f.mjs` — `isNotFound`, the `ENOENT`-only test this - deliberately leaves alone. -- `fjs/effects/node/types.ts` — `FileStat`, which grows `isDirectory` for the - root check. -- `fjs/cas/module.f.mjs` and `fjs/cas/evo/module.f.mjs` — its other two - callers, whose documented readings settle that question. -- `fjs/effects/node/virtual/module.f.mjs` — the file system that would grow the - error, and the one `proof.f.mjs` already drives `respond` through. -- [stat-then-read](./stat-then-read.md) — the request-local replace-underneath - race, which is what the `ENOTDIR` re-check degrades to, and which a startup - check on its own would have been much worse than. diff --git a/fjs/web/todo/stat-then-read.md b/fjs/web/todo/stat-then-read.md index e9fa2a420..5824da3d2 100644 --- a/fjs/web/todo/stat-then-read.md +++ b/fjs/web/todo/stat-then-read.md @@ -55,7 +55,11 @@ pass. ### Related -- [`fjs/web`](../README.md) — the response table this race can contradict. +- [`fjs/web`](../README.md) — the response table this race can contradict, and + "A path that descends through a file", whose `ENOTDIR` mapping re-stats the + root and so degrades a *permanent* wrong status into this request-local one. + A root held **open** would answer that case and the deleted-root case with no + re-check at all, which is a second reason to want this effect. - [symlink-containment](./symlink-containment.md) — the other guard that cannot be written against a name. - `fjs/effects/node/module.mjs` — `readFile`, whose own size check throws where From 8d6eefbb34ccc49ba203042d8fd5cf6ea577ea76 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 10:34:34 +0000 Subject: [PATCH 155/370] changelog: entry for #1751 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BRNUuhnny5cNz1NFa5KoDV --- changelog/unreleased/1751.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog/unreleased/1751.md diff --git a/changelog/unreleased/1751.md b/changelog/unreleased/1751.md new file mode 100644 index 000000000..a70b15539 --- /dev/null +++ b/changelog/unreleased/1751.md @@ -0,0 +1,8 @@ +- `web`: a request that descends through a regular file (`GET /README.md/`) + answers `404` instead of `500 io error: ENOTDIR`, so it no longer discloses + whether a file is there; `fjs web` refuses a root that is not a directory + before it binds, instead of failing per request +- **BREAKING CHANGES:** `effects/node`: `FileStat` gains `isDirectory` — + `isFile === false` does not mean "directory" — so a runner implementing + `Stat` must return it; the virtual file system now reports `ENOTDIR` for a + path descending through a non-directory From e2f679c7025d1a915712cae35def23d213878f24 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 11:07:46 +0000 Subject: [PATCH 156/370] web: record why the root check is isDirectory and not readdir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A directory may be traversable without being listable — mode `--x` permits opening a known path under it while `readdir` fails `EACCES` — so a root this server can serve would be refused at startup. The argument was in the deleted todo and in the PR description, neither of which survives as tracked documentation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BRNUuhnny5cNz1NFa5KoDV --- fjs/web/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/fjs/web/README.md b/fjs/web/README.md index 1a5f5404c..bd3d679cc 100644 --- a/fjs/web/README.md +++ b/fjs/web/README.md @@ -185,6 +185,13 @@ holding the root **open** and resolving beneath the handle, which is "is a directory", since a FIFO, a device and a socket answer that too, and serving one of those as a root is the same mistake as serving a file. +**Not `readdir(root)`**, which is the obvious alternative and needs no new +operation. It answers a different question: a directory may be traversable +without being listable — mode `--x` permits opening a known path under it while +`readdir` fails `EACCES` — so a root this server can serve perfectly well would +be refused at startup. Reading a whole directory only to discard it is the +smaller objection. + ### The size limit `readFile` yields a single `Vec`, which caps at 131,072 bytes, and From 0dffe551e34f621ef3fb3f5b3363c331da521800 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:22:28 +0000 Subject: [PATCH 157/370] effects: the operation vocabulary is not node's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OpResult`, `IoError`, `IoErrorInfo`, `IoChannel`, `IoResult` and the `ioError` / `toIoError` / `isNotFound` constructors were declared in `effects/node`, but nothing in them names a host: "the runner cannot dispatch this" and "the host tried and failed" are the two ways any operation goes wrong, on any host. They move to `effects/` beside `NotImplemented`, which `OpResult` is defined in terms of. The misfiling already had a victim: `effects/memory/types.ts` — which has no host at all — imported `OpResult` from `../node/types.ts`. It now takes it from the layer it belongs to. `effects/node` re-exports every moved name, so the several dozen modules that reach for them through it are unchanged, and an operation's declaration still reads as one vocabulary. Groundwork for step 4 of emergent_testing/todo/share-browser-console-runner.md: a second host's operations cannot be typed while the types they are written in live in the first host's module. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/memory/types.ts | 2 +- fjs/effects/module.f.mjs | 55 +++++++++++++- fjs/effects/node/module.f.mjs | 56 +++----------- fjs/effects/node/proof.f.mjs | 52 +------------ fjs/effects/node/types.ts | 67 +++-------------- fjs/effects/proof.f.mjs | 70 ++++++++++++++++- fjs/effects/types.ts | 75 +++++++++++++++++-- .../todo/share-browser-console-runner.md | 21 +++++- 8 files changed, 232 insertions(+), 166 deletions(-) diff --git a/fjs/effects/memory/types.ts b/fjs/effects/memory/types.ts index 844dbb80c..9d3fb2834 100644 --- a/fjs/effects/memory/types.ts +++ b/fjs/effects/memory/types.ts @@ -6,7 +6,7 @@ import type { Phantom } from '../../types/phantom/types.ts' import type { Nominal } from '../../types/nominal/types.ts' -import type { OpResult } from '../node/types.ts' +import type { OpResult } from '../types.ts' /** Nominal brand version for memory keys. */ export type _MemKeyHash = '3f114fa6036a8da026b827f0c3e6d901f5e81ad9a320e431ccce31451892d286' diff --git a/fjs/effects/module.f.mjs b/fjs/effects/module.f.mjs index 382e75340..4685cfbe5 100644 --- a/fjs/effects/module.f.mjs +++ b/fjs/effects/module.f.mjs @@ -82,7 +82,7 @@ * @import { Fold } from '../types/function/operator/types.ts' * @import { Option } from '../types/option/types.ts' * @import { Result } from '../types/result/types.ts' - * @import { Commands, Effect, ErrOf, Func, MatchResult, NotImplemented, OkOf, Operation, OperationMap, PartialOperationMap } from './types.ts' + * @import { Commands, Effect, ErrOf, Func, IoChannel, IoError, IoErrorInfo, MatchResult, NotImplemented, OkOf, Operation, OperationMap, PartialOperationMap } from './types.ts' */ import { assert } from '../asserts/module.f.mjs' @@ -90,6 +90,59 @@ import { fold } from '../types/list/module.f.mjs' import { error, mapOk, ok } from '../types/result/module.f.mjs' import { at } from '../types/object/module.f.mjs' +/** + * Builds a normalized host error. The constructor exists so the shape is + * written once: every runner reports its failures through it, and a consumer + * matching on `'ioError'` knows what the payload holds. + * + * @type {(info: IoErrorInfo) => IoError} + */ +export const ioError = info => ['ioError', info] + +/** + * Normalizes a **thrown** value into an {@link IoError}: the OS error code when + * the host attached a string one, and a message that is the `Error`'s own or + * the value's string form. + * + * This is the boundary where an impure runner's `catch` becomes ordinary effect + * data. Nothing past it sees the thrown object, which is the point — a stack, a + * `cause`, and arbitrary own properties do not survive a wire hop, and a + * program that branched on them would be reading the host's implementation + * rather than the operation's contract. + * + * The `code` convention is node's in origin and not node's in reach: a browser + * `DOMException` carries a string `name` and not a `code`, so it normalizes + * through the message branch — correctly, since there is no OS code to report. + * + * @type {(e: unknown) => IoError} + */ +export const toIoError = e => { + const message = e instanceof Error ? e.message : String(e) + if (typeof e !== 'object' || e === null || !('code' in e) || typeof e.code !== 'string') { + return ioError({ message }) + } + return ioError({ code: e.code, message }) +} + +/** + * True if `e` is a "file or directory does not exist" (`ENOENT`) error. + * + * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which + * {@link toIoError} keeps; the virtual interpreter reports the same code for + * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh + * store) while propagating genuine failures (permissions, corruption) rather + * than masking them. + * + * A {@link NotImplemented} is never "not found": a runner that cannot perform + * the operation has not looked for the path at all, so the two must not + * collapse into one benign branch — which is exactly what a bare `unknown` + * error channel used to allow. + * + * @type {(e: IoChannel) => boolean} + */ +export const isNotFound = ([tag, payload]) => + tag === 'ioError' && payload.code === 'ENOENT' + /** * Lifts an already-computed {@link Result} into an effect that performs no * command. diff --git a/fjs/effects/node/module.f.mjs b/fjs/effects/node/module.f.mjs index 5d1c6dddd..3ab265a7d 100644 --- a/fjs/effects/node/module.f.mjs +++ b/fjs/effects/node/module.f.mjs @@ -23,19 +23,21 @@ import { codePointListToString } from '../../text/utf16/module.f.mjs' import { reverse } from '../../types/list/module.f.mjs' import { length } from '../../types/bit_vec/module.f.mjs' import { error as resultError, ok as resultOk, unwrap } from '../../types/result/module.f.mjs' -import { do_, pure } from '../module.f.mjs' +import { do_, ioError, isNotFound, pure, toIoError } from '../module.f.mjs' import { mapStep as ioMapStep, pureError, pureOk, resultMapStep, resultStep, step as ioStep, } from '../module.f.mjs' /** - * Builds a normalized host error. The constructor exists so the shape is - * written once: every runner reports its failures through it, and a consumer - * matching on `'ioError'` knows what the payload holds. - * - * @type {(info: IoErrorInfo) => IoError} + * The host-error vocabulary — `ioError`, `toIoError`, `isNotFound` — is + * declared in [`../module.f.mjs`](../module.f.mjs) beside the effect + * representation, because none of it is node's: normalizing a thrown value and + * telling "the runner cannot" from "the host tried and failed" are what any + * host's interpreter does. Re-exported here so the modules that reach for them + * through the node module keep working, and so an operation's declaration and + * its failure constructor still read as one vocabulary. */ -export const ioError = info => ['ioError', info] +export { ioError, isNotFound, toIoError } /** * The host a {@link Listen} refuses. @@ -83,46 +85,6 @@ export const emptyHostError = ioError({ message: emptyHostMessage, }) -/** - * Normalizes a **thrown** value into an {@link IoError}: the OS error code when - * the host attached a string one, and a message that is the `Error`'s own or - * the value's string form. - * - * This is the boundary where an impure runner's `catch` becomes ordinary effect - * data. Nothing past it sees the thrown object, which is the point — a stack, a - * `cause`, and arbitrary own properties do not survive a wire hop, and a - * program that branched on them would be reading the host's implementation - * rather than the operation's contract. - * - * @type {(e: unknown) => IoError} - */ -export const toIoError = e => { - const message = e instanceof Error ? e.message : String(e) - if (typeof e !== 'object' || e === null || !('code' in e) || typeof e.code !== 'string') { - return ioError({ message }) - } - return ioError({ code: e.code, message }) -} - -/** - * True if `e` is a "file or directory does not exist" (`ENOENT`) error. - * - * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which - * {@link toIoError} keeps; the virtual interpreter reports the same code for - * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh - * store) while propagating genuine failures (permissions, corruption) rather - * than masking them. - * - * A {@link NotImplemented} is never "not found": a runner that cannot perform - * the operation has not looked for the path at all, so the two must not - * collapse into one benign branch — which is exactly what a bare `unknown` - * error channel used to allow. - * - * @type {(e: IoChannel) => boolean} - */ -export const isNotFound = ([tag, payload]) => - tag === 'ioError' && payload.code === 'ENOENT' - /** * `NodeOp`'s commands as data, so a runner that implements only part of them * can still tell an operation it lacks from a `Do` node whose `command` was diff --git a/fjs/effects/node/proof.f.mjs b/fjs/effects/node/proof.f.mjs index e04035da3..8808cc7d1 100644 --- a/fjs/effects/node/proof.f.mjs +++ b/fjs/effects/node/proof.f.mjs @@ -10,7 +10,7 @@ import { empty, isVec, uint, vec, vec8 } from "../../types/bit_vec/module.f.mjs" import { utf8, utf8ToString } from "../../text/module.f.mjs" import { match } from "../module.f.mjs" import { mapStep, step as ioStep } from "../module.f.mjs" -import { both, errorMessage, errorSummary, exitStep, fetch, ioError, isNotFound, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, toIoError, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" +import { both, errorMessage, errorSummary, exitStep, fetch, ioError, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" import { create as memCreate, read as memRead, write as memWrite } from "../memory/module.f.mjs" import { empty as listEmpty, nonEmpty as listNonEmpty } from "../list/module.f.mjs" import { emptyState, virtual } from "./virtual/module.f.mjs" @@ -50,56 +50,6 @@ const assertOk = (r, expected) => { } export const proof = { - // The one boundary where a runner's `catch` becomes effect data: whatever - // was thrown is reduced to a code (when the host attached a string one) - // and a message. - toIoError: { - error: () => { - assertIoMessage(toIoError(new Error('boom')), 'boom') - }, - withCode: () => { - const e = toIoError(Object.assign(new Error('missing'), { code: 'ENOENT' })) - assert(e[0] === 'ioError', e) - assertEq(e[1].code, 'ENOENT', e) - assertEq(e[1].message, 'missing', e) - }, - // A thrown non-`Error` still normalizes: the value's string form is the - // message, and there is no code to carry. - string: () => { - const e = toIoError('plain') - assert(e[0] === 'ioError', e) - assertEq(e[1].code, undefined, e) - assertEq(e[1].message, 'plain', e) - }, - null: () => { - assertIoMessage(toIoError(null), 'null') - }, - // An object whose `code` is not a string is not an OS error code, so it - // is dropped rather than carried as one. - nonStringCode: () => { - const e = toIoError({ code: 42 }) - assert(e[0] === 'ioError', e) - assertEq(e[1].code, undefined, e) - }, - noCode: () => { - const e = toIoError({}) - assert(e[0] === 'ioError', e) - assertEq(e[1].code, undefined, e) - }, - }, - isNotFound: { - enoent: () => { - assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) - }, - otherCode: () => { - assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) - }, - // A runner that cannot perform the operation has not looked for the - // path at all, so a missing handler is never "not found". - notImplemented: () => { - assert(!isNotFound(['notImplemented', 'readFile'])) - }, - }, errorMessage: { io: () => { assertEq(errorMessage(ioError({ message: 'disk full' })), 'disk full') diff --git a/fjs/effects/node/types.ts b/fjs/effects/node/types.ts index 886a045f3..522334f25 100644 --- a/fjs/effects/node/types.ts +++ b/fjs/effects/node/types.ts @@ -10,66 +10,21 @@ import type { MemOp } from '../memory/types.ts' import type { Nominal } from '../../types/nominal/types.ts' import type { Result } from '../../types/result/types.ts' import type { StringMap } from '../../types/object/types.ts' -import type { Effect, NotImplemented, Operation, ToAsyncOperationMap } from '../types.ts' +import type { + Effect, IoChannel, IoError, IoErrorInfo, IoResult, NotImplemented, OpResult, + Operation, ToAsyncOperationMap, +} from '../types.ts' import type { List } from '../list/types.ts' /** - * A host failure, normalized: whatever the runtime threw reduced to a - * serializable record. `code` is the OS error code when the host supplied one - * (`'ENOENT'`, `'EEXIST'`), absent otherwise. - * - * It is a tagged tuple for the same reason {@link NotImplemented} is — the two - * share an error channel, and the tag is what tells them apart. That - * distinction is the whole reason this type exists: with a bare `unknown` - * error, `NotImplemented | unknown` collapses to `unknown` and a program can no - * longer tell "this runner cannot do it" from "the host tried and failed". - * - * Normalizing also keeps the channel serializable. A thrown `Error` carries a - * stack, a `cause`, and arbitrary own properties; none of it survives a wire - * hop, and a runner in another process could not reproduce it. - */ -export type IoError = readonly['ioError', IoErrorInfo] - -export type IoErrorInfo = { - readonly code?: string - readonly message: string -} - -/** - * The result of an operation with no failures of its own: it either produces - * its value or reports that the runner does not implement it. - * - * Every operation's return type is a `Result`, including the ones that cannot - * fail on their own terms — an operation left on a raw contract would be a hole - * in the error channel, and a runner may omit a handler for any of them. - */ -export type OpResult = Result - -/** - * The error channel of anything that performs host IO: a normalized host - * failure, or the report that the runner does not implement the operation. - * - * It is one name rather than a union spelled at each site, and that is a - * migration property rather than brevity. An effect that does no IO *yet* is - * one added `readFile` away from doing some, and if each signature names its - * own errors, that one change walks up every enclosing signature — the failure - * mode that sank `throws` clauses elsewhere, where engineers eventually - * declared everything throwing rather than maintain the cascade. Declaring the - * standard channel once is that concession made deliberately: an IO-touching - * effect says it fails *the way node IO fails*, and gaining a new way to do so - * changes nothing above it. - * - * It is not a licence to widen. An operation with failures of its own extends - * the channel (`IoChannel | ParseError`), and a computation whose errors are - * genuinely narrower should say so — this is the default for IO, not a ceiling. - */ -export type IoChannel = NotImplemented | IoError - -/** - * The result of an operation that performs host IO: its value, a normalized - * host failure, or the missing-handler report. + * The vocabulary every operation is declared in — how a runner reports that it + * cannot dispatch, and how a host reports that it tried and failed — now lives + * in [`../types.ts`](../types.ts), beside {@link NotImplemented}, because none + * of it is node's. It is re-exported here so that the several dozen modules + * naming these through the node module keep doing so, and so a signature can go + * on reading as one vocabulary rather than two. */ -export type IoResult = Result +export type { IoChannel, IoError, IoErrorInfo, IoResult, OpResult } // all diff --git a/fjs/effects/proof.f.mjs b/fjs/effects/proof.f.mjs index 102ad602c..2968d8f13 100644 --- a/fjs/effects/proof.f.mjs +++ b/fjs/effects/proof.f.mjs @@ -1,12 +1,12 @@ /** - * @import { Effect, Func, Operation } from './types.ts' + * @import { Effect, Func, IoChannel, Operation } from './types.ts' * @import { Result } from '../types/result/types.ts' */ import { - catchStep, do_, foldStep, forEachStep, history, historyStep, mapStep, match, - partialMatch, pure, pureError, pureOk, resultMapStep, resultStep, runPure, step, - unwrapStep, + catchStep, do_, foldStep, forEachStep, history, historyStep, ioError, + isNotFound, mapStep, match, partialMatch, pure, pureError, pureOk, + resultMapStep, resultStep, runPure, step, toIoError, unwrapStep, } from './module.f.mjs' import { error, ok } from '../types/result/module.f.mjs' import { assert, assertEq, todo } from '../asserts/module.f.mjs' @@ -175,7 +175,69 @@ const checked = v => { */ const show = e => `${e}` +/** + * Asserts that a channel error is a host failure carrying `message`. Every + * runner reports through the same normalized `IoError`, so a proof names the + * message rather than the shape. + * + * @type {(e: IoChannel, message: string) => void} + */ +const assertIoMessage = (e, message) => { + assert(e[0] === 'ioError', e) + assertEq(e[1].message, message) +} + export const proof = { + // The one boundary where a runner's `catch` becomes effect data: whatever + // was thrown is reduced to a code (when the host attached a string one) + // and a message. + toIoError: { + error: () => { + assertIoMessage(toIoError(new Error('boom')), 'boom') + }, + withCode: () => { + const e = toIoError(Object.assign(new Error('missing'), { code: 'ENOENT' })) + assert(e[0] === 'ioError', e) + assertEq(e[1].code, 'ENOENT', e) + assertEq(e[1].message, 'missing', e) + }, + // A thrown non-`Error` still normalizes: the value's string form is the + // message, and there is no code to carry. + string: () => { + const e = toIoError('plain') + assert(e[0] === 'ioError', e) + assertEq(e[1].code, undefined, e) + assertEq(e[1].message, 'plain', e) + }, + null: () => { + assertIoMessage(toIoError(null), 'null') + }, + // An object whose `code` is not a string is not an OS error code, so it + // is dropped rather than carried as one. + nonStringCode: () => { + const e = toIoError({ code: 42 }) + assert(e[0] === 'ioError', e) + assertEq(e[1].code, undefined, e) + }, + noCode: () => { + const e = toIoError({}) + assert(e[0] === 'ioError', e) + assertEq(e[1].code, undefined, e) + }, + }, + isNotFound: { + enoent: () => { + assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) + }, + otherCode: () => { + assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) + }, + // A runner that cannot perform the operation has not looked for the + // path at all, so a missing handler is never "not found". + notImplemented: () => { + assert(!isNotFound(['notImplemented', 'readFile'])) + }, + }, runPure: { ok: () => { assertPure(pure(ok(5)), ok(5)) diff --git a/fjs/effects/types.ts b/fjs/effects/types.ts index 4164e2db9..80662aaf6 100644 --- a/fjs/effects/types.ts +++ b/fjs/effects/types.ts @@ -21,11 +21,11 @@ import type { * `error(notImplemented(command))` through the command's own output — so an * operation whose return admitted no error would be a hole in that mechanism: * there would be nowhere to put the refusal. Every *host* operation already - * returned `OpResult<…>` or `IoResult<…>` when this constraint was added, and - * so did four of the six declared inside proofs — through a bare `Result` - * rather than either alias, since those two are node conveniences. The two in - * `./proof.f.mjs` returned a bare `number`, and the commit that added the rule - * rewrote them. + * returned {@link OpResult} or {@link IoResult} when this constraint was added, + * and so did four of the six declared inside proofs — through a bare `Result` + * rather than either alias, which were then declared in `./node/types.ts` and + * so read as node conveniences. The two in `./proof.f.mjs` returned a bare + * `number`, and the commit that added the rule rewrote them. * * It is also the latch the whole representation now rests on. An operation * *cannot* be declared infallible, so every {@link Effect} built from one has a @@ -65,6 +65,71 @@ export type Operation = */ export type NotImplemented = readonly['notImplemented', string] +/** + * The result of an operation with no failures of its own: it either produces + * its value or reports that the runner does not implement it. + * + * Every operation's return type is a `Result`, including the ones that cannot + * fail on their own terms — an operation left on a raw contract would be a hole + * in the error channel, and a runner may omit a handler for any of them. + */ +export type OpResult = Result + +/** + * A host failure, normalized: whatever the runtime threw reduced to a + * serializable record. `code` is the OS error code when the host supplied one + * (`'ENOENT'`, `'EEXIST'`), absent otherwise. + * + * It is a tagged tuple for the same reason {@link NotImplemented} is — the two + * share an error channel, and the tag is what tells them apart. That + * distinction is the whole reason this type exists: with a bare `unknown` + * error, `NotImplemented | unknown` collapses to `unknown` and a program can no + * longer tell "this runner cannot do it" from "the host tried and failed". + * + * Normalizing also keeps the channel serializable. A thrown `Error` carries a + * stack, a `cause`, and arbitrary own properties; none of it survives a wire + * hop, and a runner in another process could not reproduce it. + */ +export type IoError = readonly['ioError', IoErrorInfo] + +export type IoErrorInfo = { + readonly code?: string + readonly message: string +} + +/** + * The error channel of anything that performs host IO: a normalized host + * failure, or the report that the runner does not implement the operation. + * + * It is one name rather than a union spelled at each site, and that is a + * migration property rather than brevity. An effect that does no IO *yet* is + * one added `readFile` away from doing some, and if each signature names its + * own errors, that one change walks up every enclosing signature — the failure + * mode that sank `throws` clauses elsewhere, where engineers eventually + * declared everything throwing rather than maintain the cascade. Declaring the + * standard channel once is that concession made deliberately: an IO-touching + * effect says it fails *the way host IO fails*, and gaining a new way to do so + * changes nothing above it. + * + * It is not a licence to widen. An operation with failures of its own extends + * the channel (`IoChannel | ParseError`), and a computation whose errors are + * genuinely narrower should say so — this is the default for IO, not a ceiling. + * + * **It is not node's, though it was declared there.** Nothing in either half + * names a host: a runner that cannot dispatch and a host that tried and failed + * are the two ways any operation goes wrong, on any host. Living in + * `./node/types.ts` meant that `./memory/types.ts` — which has no host at + * all — reached into the node module for {@link OpResult}, and that a second + * host's operations could not be typed without doing the same. + */ +export type IoChannel = NotImplemented | IoError + +/** + * The result of an operation that performs host IO: its value, a normalized + * host failure, or the missing-handler report. + */ +export type IoResult = Result + /** * An `Effect` is the raw value: a {@link Pure} thunk yielding * `Result`, or a {@link Do} node describing a command to perform. It is diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 0bb159d66..2697d3976 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -129,8 +129,27 @@ and is reviewable without the next one. `await`, `fetch`, `import`, `now`, `sandbox`) out of `effects/node` into a shared module that `effects/node` re-exports unchanged, so nothing has to move with them. + + **The vocabulary went first, and it was not speculative.** Before an + operation can move, the types it is *declared in* have to have a home: + `OpResult`, `IoError`, `IoErrorInfo`, `IoChannel`, `IoResult` and the + `ioError`/`toIoError`/`isNotFound` constructors were all in + `effects/node`, and none of them names a host — "the runner cannot + dispatch" and "the host tried and failed" are how *any* operation goes + wrong. That misfiling already had a victim: `effects/memory/types.ts`, + which has no host at all, imported `OpResult` from `../node/types.ts`. + So that move is separation of concerns with a consumer today + ([DESIGN.md §4](../../../DESIGN.md)), not an extraction on the promise of + one — which is the test the operations themselves have yet to pass, and + why they wait for step 5. `effects/node` re-exports every moved name, so + the several dozen modules that reach for them through it are untouched. - [ ] **5. A browser interpreter** for exactly those operations, with no - scheduling policy of its own. + scheduling policy of its own. This is also what earns step 4's *operation* + move its second consumer: until a second host implements `now`, `sandbox`, + `await` and `all`, moving them out of `effects/node` makes nothing shorter + or clearer, and DESIGN.md §4 says to extract at the second real consumer + rather than before it. The two are therefore one design in two commits, + not one step deferred. - [x] **6. One reporter.** The event stream — a leaf landed, a run ended — that both hosts subscribe to. Step 2 gave them the *value*; this gave them the seam it travels through. `Reporter.result` now receives the From f5823bc905cfde3f9be80c306708a5b4ecaebc3e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:25:16 +0000 Subject: [PATCH 158/370] virtual: an inherited name is not an entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dir[name]` on a plain object finds `Object.prototype`'s names, so an empty root answered for `toString` and descended into `__proto__`. The `ENOTDIR` mapping made that visible: `stat('toString/x')` answered `ENOTDIR` — "the name before this one exists" — where a host says `ENOENT`. `entryOf` reads own names only, and `operation`'s descent and `statPath` use it. The other operations do their own lookups and still read through the prototype; that is pre-existing and filed as virtual/todo/prototype-names-read-as-entries.md rather than swept in here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BRNUuhnny5cNz1NFa5KoDV --- fjs/effects/node/virtual/module.f.mjs | 28 ++++++++- fjs/effects/node/virtual/proof.f.mjs | 25 ++++++++ .../todo/prototype-names-read-as-entries.md | 60 +++++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 fjs/effects/node/virtual/todo/prototype-names-read-as-entries.md diff --git a/fjs/effects/node/virtual/module.f.mjs b/fjs/effects/node/virtual/module.f.mjs index 496d20596..cafb475ed 100644 --- a/fjs/effects/node/virtual/module.f.mjs +++ b/fjs/effects/node/virtual/module.f.mjs @@ -56,6 +56,26 @@ const isJsModule = entity => typeof entity === 'function' */ const isDir = entity => !isBinFile(entity) && !isJsModule(entity) +const { hasOwn } = Object + +/** + * The entry `dir` holds at `name`, or `undefined` if it holds none. + * + * **Own names only.** A `Dir` is a plain object, so `dir[name]` also finds + * whatever `Object.prototype` holds: `dir['toString']` is a function — which + * this file system reads as a `JsModule` — and `dir['__proto__']` is an object, + * which it reads as a directory. A host has no such names, so an empty root + * that answers for `toString` or descends into `__proto__` models nothing, and + * a caller's absent-path branch could be reached by a name that is not absent + * here while being absent everywhere else. + * + * An own name holding `undefined` is absent too — `Dir`'s values are optional, + * and every operation here already reads `undefined` as "no entry". + * + * @type {(dir: Dir, name: string) => _Entity | undefined} + */ +const entryOf = (dir, name) => hasOwn(dir, name) ? dir[name] : undefined + /** * @template T * @param {(dir: Dir, path: readonly string[]) => readonly [Dir, T]} op @@ -68,7 +88,7 @@ const operation = op => { return op(dir, path) } const [first, ...rest] = path - const subDir = dir[first] + const subDir = entryOf(dir, first) if (subDir === undefined || !isDir(subDir)) { return op(dir, path) } @@ -417,7 +437,11 @@ const writeBytesOp = (path, offset, data) => operation(writeBytesRawOp(offset, d */ const statPath = readOperation((dir, path) => { if (path.length === 0) { return directory } - const file = dir[path[0]] + // `entryOf`, not `dir[path[0]]`: an inherited name is not an entry, and + // reading one as though it were would answer `ENOTDIR` — "the name before + // this one exists" — for `toString/x` under an empty root, where a host + // says `ENOENT`. + const file = entryOf(dir, path[0]) if (file === undefined) { return enoent } if (path.length !== 1) { return enotdir } // `isBinFile` rather than a local `Array.isArray`: which entity kind a name diff --git a/fjs/effects/node/virtual/proof.f.mjs b/fjs/effects/node/virtual/proof.f.mjs index 7695b3dc0..2488e274f 100644 --- a/fjs/effects/node/virtual/proof.f.mjs +++ b/fjs/effects/node/virtual/proof.f.mjs @@ -460,6 +460,31 @@ export const proof = { assertEq(result[1].isDirectory, false) assertEq(result[1].size, 0) }, + statOnInheritedName: () => { + // A `Dir` is a plain object, so `dir[name]` finds `Object.prototype`'s + // names too — and this file system would have read them as entries: + // `toString` is a function, which is its `JsModule`, and `__proto__` is + // an object, which is a directory. A host has none of these names, so + // every one of them is absent here. + // + // Reachable from an *empty* root, which is what makes it worth pinning: + // no fixture has to contain anything for a caller to ask. + /** @type {(path: string) => void} */ + const absent = path => { + const [, result] = virtual(emptyState)(stat(path)) + assert(result[0] === 'error', [path, result]) + assertIoCode(result[1], 'ENOENT') + } + absent('toString') + absent('constructor') + // Not `ENOTDIR`: that answer claims the name before the slash exists, + // which is the reading an inherited name must not earn. + absent('toString/x') + // `__proto__` is the one that reads as a *directory* — `operation` + // would descend into `Object.prototype` and stat it as the root. + absent('__proto__') + absent('__proto__/x') + }, statOnRegularFile: () => { /** @type {Dir} */ const root = { 'a.txt': [vec8(0x41n)] } diff --git a/fjs/effects/node/virtual/todo/prototype-names-read-as-entries.md b/fjs/effects/node/virtual/todo/prototype-names-read-as-entries.md new file mode 100644 index 000000000..d68da41f4 --- /dev/null +++ b/fjs/effects/node/virtual/todo/prototype-names-read-as-entries.md @@ -0,0 +1,60 @@ +## Inherited names read as entries in every operation but `stat` + +**Priority:** P3 +**Status:** open + +### Problem + +A `Dir` is a plain object, so `dir[name]` finds whatever `Object.prototype` +holds. `dir['toString']` is a function, which this file system reads as a +`JsModule`; `dir['__proto__']` is an object, which it reads as a directory. A +host has no such names, so every answer built from one models nothing. + +`operation`'s descent and `statPath` now read **own** names only, through +`entryOf` in [`../module.f.mjs`](../module.f.mjs) — added with the `ENOTDIR` +mapping, because reading an inherited name there answered `ENOTDIR` ("the name +before this one exists") for `toString/x` under an empty root, where a host says +`ENOENT`. Every *other* operation still does its own lookup and still reads +through the prototype. Measured against `emptyState` after that fix: + +| call | today | a host | +|---|---|---| +| `access('toString')` | `ok` — the path exists | `ENOENT` | +| `readFile('toString')` | throws `'toString' is a JsModule; readFile not supported` | `ENOENT` | +| `stat('toString')` | `ENOENT` | `ENOENT` | + +The `readFile` row is the sharp one: a `throw` is not in the effect's channel at +all, so a program cannot answer it — and FunctionalScript has no `try`/`catch` +to contain it. + +Nothing reaches this from FunctionalScript, where a `Dir` is a fixture the proof +author wrote: it takes a path naming an inherited property, which is why this is +filed rather than fixed under the pull request that found it +([#1751](https://github.com/functionalscript/functionalscript/pull/1751), where +the Codex review bot raised it). + +### Proposal + +Read every entry through `entryOf`. It is already there and already documents +why; what is left is the call sites, each a one-expression change: + +`readFile`, `import_`, `writeFileOp`, `access`, `rmOp`, `extractEntity`, +`createExclusiveOp`, `writeBytesRawOp`, and `readBytes`'s own lookup. + +`readdir` needs nothing — it walks `Object.entries`, which is own-only already, +and is the shape the others should be measured against. + +### Tasks + +- [ ] Route every entry lookup in `../module.f.mjs` through `entryOf`. +- [ ] Pin `access` and `readFile` against an inherited name from an empty root, + beside `statOnInheritedName` in [`../proof.f.mjs`](../proof.f.mjs). +- [ ] Check whether any fixture in the repository relies on the current + reading — none is expected, since it takes a deliberately chosen name. + +### Related + +- [`../module.f.mjs`](../module.f.mjs) — `entryOf`, the fix, and the reason it + exists. +- `fjs/effects/module.f.mjs` — its own note on a record whose keys reach + `Object.prototype`, the same hazard in the memory runner's store. From 3351d917dd2009d6d7506f36ad23a3c0cac5a394 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:29:08 +0000 Subject: [PATCH 159/370] changelog: entry for #1754 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BRNUuhnny5cNz1NFa5KoDV --- changelog/unreleased/1754.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/unreleased/1754.md diff --git a/changelog/unreleased/1754.md b/changelog/unreleased/1754.md new file mode 100644 index 000000000..c752be0b4 --- /dev/null +++ b/changelog/unreleased/1754.md @@ -0,0 +1,3 @@ +- `effects/node/virtual`: a path naming an inherited property (`toString`, + `__proto__`) is absent rather than an entry, in `stat` and in the descent + every operation shares From 6ca864fd989ad1318f71b88b58a0c8e419d389c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:29:34 +0000 Subject: [PATCH 160/370] fjs/web: inline the answer channel's private members main's ENOTDIR re-check added a use of the `_TooLarge`/`_NotRegular` typedefs this branch inlined away; spell them inline there too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/web/module.f.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fjs/web/module.f.mjs b/fjs/web/module.f.mjs index 665e004c9..d212cbe1e 100644 --- a/fjs/web/module.f.mjs +++ b/fjs/web/module.f.mjs @@ -530,7 +530,7 @@ const isServableRoot = s => s[0] === 'ok' && s[1].isDirectory * [stat-then-read](./todo/stat-then-read.md) already describes: a wrong status * in a vanishing window rather than a wrong status forever. * - * @type {(root: string) => (path: string) => (r: Result) => Effect} + * @type {(root: string) => (path: string) => (r: Result) => Effect} */ const answer = root => path => r => { const hostAnswer = fileResponse(path)(r) From e67bb52b7346f45c00408a3b7d923f29b0204e49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:31:22 +0000 Subject: [PATCH 161/370] effects: isNotFound is node's after all, and say what moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found `effects/todo/node-module-layering.md`, an open design that had already decided two of these questions. It was right about one and outdated about the other, so this reconciles both rather than leaving contradictory guidance for whoever implements it. `isNotFound` moves back to `effects/node`, with its proofs. It reads `ENOENT`, a POSIX filesystem code a host without a filesystem never reports, so it is a node predicate — "none of it is node's" was overreach for that one function. Being about a host failure does not make a thing host-agnostic; being about no host in particular does. The issue's other ruling — that `IoResult` must not move to the core — is overturned in the file with the reason: it was reasoned without a second host, and a browser interpreter cannot declare `fetch` or `import` without that alias existing somewhere shared. Its stated test ("not an effect constructor or combinator") also did not describe the file it was applied to, where `NotImplemented` already lives. The one-site `fjs/media/type` cleanup it proposed survives and stays open. Also records why a re-export here is not the "no shims behind" case that issue rules out: node's own operations are declared in these types, so it re-exports what it genuinely uses. Changelog entry added — the core module gains public exports even though no old import path changed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/1753.md | 4 ++ fjs/effects/module.f.mjs | 19 ------ fjs/effects/node/module.f.mjs | 46 +++++++++++--- fjs/effects/node/proof.f.mjs | 15 ++++- fjs/effects/proof.f.mjs | 19 +----- fjs/effects/todo/node-module-layering.md | 79 +++++++++++++++--------- 6 files changed, 107 insertions(+), 75 deletions(-) create mode 100644 changelog/unreleased/1753.md diff --git a/changelog/unreleased/1753.md b/changelog/unreleased/1753.md new file mode 100644 index 000000000..e46b9e44f --- /dev/null +++ b/changelog/unreleased/1753.md @@ -0,0 +1,4 @@ +- `effects`: the vocabulary every operation is declared in — `OpResult`, + `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult`, and the `ioError` / + `toIoError` constructors — is now importable from the core module, not only + through `effects/node`, which re-exports it unchanged diff --git a/fjs/effects/module.f.mjs b/fjs/effects/module.f.mjs index 4685cfbe5..1440876a1 100644 --- a/fjs/effects/module.f.mjs +++ b/fjs/effects/module.f.mjs @@ -124,25 +124,6 @@ export const toIoError = e => { return ioError({ code: e.code, message }) } -/** - * True if `e` is a "file or directory does not exist" (`ENOENT`) error. - * - * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which - * {@link toIoError} keeps; the virtual interpreter reports the same code for - * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh - * store) while propagating genuine failures (permissions, corruption) rather - * than masking them. - * - * A {@link NotImplemented} is never "not found": a runner that cannot perform - * the operation has not looked for the path at all, so the two must not - * collapse into one benign branch — which is exactly what a bare `unknown` - * error channel used to allow. - * - * @type {(e: IoChannel) => boolean} - */ -export const isNotFound = ([tag, payload]) => - tag === 'ioError' && payload.code === 'ENOENT' - /** * Lifts an already-computed {@link Result} into an effect that performs no * command. diff --git a/fjs/effects/node/module.f.mjs b/fjs/effects/node/module.f.mjs index 3ab265a7d..e3bd8e3f6 100644 --- a/fjs/effects/node/module.f.mjs +++ b/fjs/effects/node/module.f.mjs @@ -23,21 +23,26 @@ import { codePointListToString } from '../../text/utf16/module.f.mjs' import { reverse } from '../../types/list/module.f.mjs' import { length } from '../../types/bit_vec/module.f.mjs' import { error as resultError, ok as resultOk, unwrap } from '../../types/result/module.f.mjs' -import { do_, ioError, isNotFound, pure, toIoError } from '../module.f.mjs' +import { do_, ioError, pure, toIoError } from '../module.f.mjs' import { mapStep as ioMapStep, pureError, pureOk, resultMapStep, resultStep, step as ioStep, } from '../module.f.mjs' /** - * The host-error vocabulary — `ioError`, `toIoError`, `isNotFound` — is - * declared in [`../module.f.mjs`](../module.f.mjs) beside the effect - * representation, because none of it is node's: normalizing a thrown value and - * telling "the runner cannot" from "the host tried and failed" are what any - * host's interpreter does. Re-exported here so the modules that reach for them - * through the node module keep working, and so an operation's declaration and - * its failure constructor still read as one vocabulary. + * `ioError` and `toIoError` are declared in + * [`../module.f.mjs`](../module.f.mjs) beside the effect representation, + * because neither is node's: normalizing a thrown value into serializable + * effect data is what any host's interpreter does at its `catch`. They are + * re-exported here so the modules that reach for them through the node module + * keep working, and so an operation's declaration and its failure constructor + * still read as one vocabulary. + * + * {@link isNotFound} stayed, and the difference is the test for where any of + * this belongs: it reads `ENOENT`, a POSIX filesystem code that no browser + * ever reports. Being about a *host failure* does not make a thing + * host-agnostic — being about no host in particular does. */ -export { ioError, isNotFound, toIoError } +export { ioError, toIoError } /** * The host a {@link Listen} refuses. @@ -85,6 +90,29 @@ export const emptyHostError = ioError({ message: emptyHostMessage, }) +/** + * True if `e` is a "file or directory does not exist" (`ENOENT`) error. + * + * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which + * {@link toIoError} keeps; the virtual interpreter reports the same code for + * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh + * store) while propagating genuine failures (permissions, corruption) rather + * than masking them. + * + * A {@link NotImplemented} is never "not found": a runner that cannot perform + * the operation has not looked for the path at all, so the two must not + * collapse into one benign branch — which is exactly what a bare `unknown` + * error channel used to allow. + * + * **It belongs to this layer, unlike the constructors above.** `ENOENT` is a + * POSIX filesystem code; a host without a filesystem never reports one, so a + * shared `isNotFound` would be a node predicate wearing a host-agnostic name. + * + * @type {(e: IoChannel) => boolean} + */ +export const isNotFound = ([tag, payload]) => + tag === 'ioError' && payload.code === 'ENOENT' + /** * `NodeOp`'s commands as data, so a runner that implements only part of them * can still tell an operation it lacks from a `Do` node whose `command` was diff --git a/fjs/effects/node/proof.f.mjs b/fjs/effects/node/proof.f.mjs index 8808cc7d1..bd094f375 100644 --- a/fjs/effects/node/proof.f.mjs +++ b/fjs/effects/node/proof.f.mjs @@ -10,7 +10,7 @@ import { empty, isVec, uint, vec, vec8 } from "../../types/bit_vec/module.f.mjs" import { utf8, utf8ToString } from "../../text/module.f.mjs" import { match } from "../module.f.mjs" import { mapStep, step as ioStep } from "../module.f.mjs" -import { both, errorMessage, errorSummary, exitStep, fetch, ioError, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" +import { both, errorMessage, errorSummary, exitStep, fetch, ioError, isNotFound, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" import { create as memCreate, read as memRead, write as memWrite } from "../memory/module.f.mjs" import { empty as listEmpty, nonEmpty as listNonEmpty } from "../list/module.f.mjs" import { emptyState, virtual } from "./virtual/module.f.mjs" @@ -50,6 +50,19 @@ const assertOk = (r, expected) => { } export const proof = { + isNotFound: { + enoent: () => { + assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) + }, + otherCode: () => { + assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) + }, + // A runner that cannot perform the operation has not looked for the + // path at all, so a missing handler is never "not found". + notImplemented: () => { + assert(!isNotFound(['notImplemented', 'readFile'])) + }, + }, errorMessage: { io: () => { assertEq(errorMessage(ioError({ message: 'disk full' })), 'disk full') diff --git a/fjs/effects/proof.f.mjs b/fjs/effects/proof.f.mjs index 2968d8f13..2f9373d7d 100644 --- a/fjs/effects/proof.f.mjs +++ b/fjs/effects/proof.f.mjs @@ -4,9 +4,9 @@ */ import { - catchStep, do_, foldStep, forEachStep, history, historyStep, ioError, - isNotFound, mapStep, match, partialMatch, pure, pureError, pureOk, - resultMapStep, resultStep, runPure, step, toIoError, unwrapStep, + catchStep, do_, foldStep, forEachStep, history, historyStep, mapStep, + match, partialMatch, pure, pureError, pureOk, resultMapStep, resultStep, + runPure, step, toIoError, unwrapStep, } from './module.f.mjs' import { error, ok } from '../types/result/module.f.mjs' import { assert, assertEq, todo } from '../asserts/module.f.mjs' @@ -225,19 +225,6 @@ export const proof = { assertEq(e[1].code, undefined, e) }, }, - isNotFound: { - enoent: () => { - assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) - }, - otherCode: () => { - assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) - }, - // A runner that cannot perform the operation has not looked for the - // path at all, so a missing handler is never "not found". - notImplemented: () => { - assert(!isNotFound(['notImplemented', 'readFile'])) - }, - }, runPure: { ok: () => { assertPure(pure(ok(5)), ok(5)) diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index 5c4821075..414f578fb 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -54,7 +54,8 @@ provides*. Proposed destinations: | `fjs/effects/sandbox/module.f.mjs` | `Sandbox`, `SandboxResult`, `sandbox`, `Await`, `awaitIfPromise` — the "run foreign code and observe what happened" pair | | `fjs/effects/console/module.f.mjs` | `Read`, `Write`, `ReadConsoles`, `WriteConsoles`, `Console`, `log`, `error`, `readLine`, `errorExit`, and a **new named `Std`** (see below) | | `fjs/effects/test/module.f.mjs` | `Test`, `TestFn`, `TestContext`, `test` — registration with an external framework, not I/O | -| stays in `fjs/effects/node` | `Fs` and its members, `Http`, `Fetch`, `Import`, `Forever`, `Now`, `RandomInt`, `IoResult`, `isNotFound`, `Env`, `Engine`, `NodeOp`, `NodeProgramOptions`, `Program`, `NodeProgram`, `NodeOperationMap` | +| stays in `fjs/effects/node` | `Fs` and its members, `Http`, `Fetch`, `Import`, `Forever`, `Now`, `RandomInt`, `isNotFound`, `Env`, `Engine`, `NodeOp`, `NodeProgramOptions`, `Program`, `NodeProgram`, `NodeOperationMap` | +| already moved to `fjs/effects` | `OpResult`, `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult`, `ioError`, `toIoError` — the vocabulary every operation is declared in; `effects/node` re-exports them (see the judgement call below) | `NodeOp` stays where it is and keeps unioning every family — it is the *runner's* op-set, which is legitimately "everything this host can do", and both @@ -67,34 +68,37 @@ Judgement calls worth deciding explicitly rather than by accident: - **`Now` / `RandomInt` stay.** They are ambient host capabilities with no cross-runtime abstraction to gain, and no consumer outside `fjs/cas` and the interpreters. Moving them would be motion without a reader benefit. -- **`isNotFound` stays.** It encodes Node's `ENOENT` shape specifically; that - *is* a Node concern. -- **`IoResult` stays too — pure consumers should stop importing it instead.** - An earlier draft of this issue moved it to the effects core, on the reasoning - that core already imports `Result` so the move costs no new dependency. That - reasoning picks a destination by convenience rather than by concern, and the - destination is wrong on its own terms: `Result` is not an effect - constructor or combinator, so moving it would swap Node coupling for - core-effects coupling and leave a non-effect type in the effects core. - `fjs/types/result` is not the answer either — the *name* is about the host I/O - boundary ("the error is whatever the host threw"), and a generic types module - should not mint I/O vocabulary. - - Read the other way, `IoResult` is exactly a Node-layer contract and belongs - beside the operations it describes. The fix for a **pure** consumer is to - spell the underlying type, not to relocate the alias: - `fjs/media/type/module.f.mjs:45` imports `IoResult` from - `../../effects/node/types.ts` purely to write `IoResult` and - `IoResult`; writing `Result` from - `fjs/types/result` says the same thing and drops the `effects/node` import - **entirely**, which is a better outcome than moving where it points. - [fold-stream-combinator](./fold-stream-combinator.md) reached the same - conclusion independently for `fjs/effects/list` — its `Result`-spelled - signature is the right design, not the workaround that issue calls it. - - This is an independent, one-site cleanup: it neither depends on nor supports - the moves below. Listed here because that is where the wrong answer was - written down; it can land on its own. +- **`isNotFound` stays, and this was tested.** It encodes `ENOENT` + specifically — a POSIX filesystem code that a host without a filesystem never + reports — so it *is* a Node-layer concern. A change that moved it to the core + along with the error vocabulary was reviewed against this line and reverted + on it: being about a *host failure* does not make a thing host-agnostic, + being about no host in particular does. +- **`IoResult` moved to the effects core, and this issue was wrong to say it + should not.** The reasoning here was that `Result` is not an + effect constructor or combinator, so the core is the wrong home and pure + consumers should spell the underlying type instead. What that reasoning did + not have was a **second host**. `IoResult` is not "exactly a Node-layer + contract": it is the shape every host's IO operations answer in, and a + browser interpreter cannot declare `fetch` or `import` without it. The same + goes for `OpResult`, `IoChannel`, `IoError` and `IoErrorInfo`, which this + issue never listed — `OpResult` is `Result`, defined + purely in terms of a type the core already owns, and `effects/memory` (no + host at all) was importing it from `effects/node`. + + The "not an effect constructor or combinator" test also did not describe the + file it was applied to: `NotImplemented` already lives in the core and is + neither. What the core holds is the vocabulary an operation is *declared* + in, and this is that. + + The one-site cleanup this bullet also proposed is still worth doing and is + now the task below: `fjs/media/type/module.f.mjs` imports `IoResult` only to + spell two signatures, and `Result` from `fjs/types/result` says + the same thing without reaching into the effects package at all. + [fold-stream-combinator](./fold-stream-combinator.md) reached that conclusion + independently for `fjs/effects/list`. That a pure consumer should not name an + IO alias and that a *second host* needs one to exist somewhere shared are + both true; the old bullet collapsed them into one answer. - **`Test` goes to an effects module, not to `fjs/emergent_testing`.** `emergent_testing` looks like the natural owner — it is the only consumer of `test` and the module that defines what a test *is* — but putting the @@ -177,6 +181,16 @@ Judgement calls worth deciding explicitly rather than by accident: - **Every move is a breaking change** to an import path. Per `AGENTS.md`, do one concern per PR, update every importer in the same PR, and prefix the CHANGELOG entry with `**BREAKING CHANGES:**`. Do not leave re-export shims behind. + + **The vocabulary move is the one exception, and for a reason that does not + generalize.** A re-export is a shim when it keeps a *dead* coupling alive — + which is the case for every move in the table above, where the whole goal is + that `fjs/text/sgr` stops naming `effects/node` at all. It is not the case + for `IoChannel` and its siblings: node's own operations are declared in + them, so `effects/node` re-exporting what it genuinely uses keeps one + vocabulary readable at one import rather than preserving a coupling anyone + wants gone. That is why that move was additive and needed no importer churn, + and why the moves below still need theirs. - **The obsolete Playwright adapter is already gone.** This task must preserve only the process-side `TestContext` fields that still have consumers. It must not use relocation as a reason to revive the Playwright engine, context, @@ -191,9 +205,14 @@ Judgement calls worth deciding explicitly rather than by accident: ### Tasks +- [x] Move the operation vocabulary (`OpResult`, `IoChannel`, `IoError`, + `IoErrorInfo`, `IoResult`, `ioError`, `toIoError`) to `fjs/effects`, + with `effects/node` re-exporting it and `effects/memory` taking + `OpResult` from the core. `isNotFound` stayed — see the judgement calls. - [ ] Independent of the moves: replace `fjs/media/type`'s `IoResult` import with `Result` from `fjs/types/result`, dropping its - `effects/node` import. `IoResult` itself does **not** move. + `effects` import — a pure consumer should not name an IO alias, whichever + module the alias lives in. - [ ] Move `All` / `all` / `allOk` / `both` to `fjs/effects/all/module.f.mjs`. `allOk` is the ok-channel wrapper over `all` and belongs with it; [allvoid-combinator](./allvoid-combinator.md) builds on it, so leaving it From 7695ae3573a2be27f374ca51ec7fa8f5bc86f455 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:31:23 +0000 Subject: [PATCH 162/370] rtti: return only what the proofs use from the schema factory The mutually-recursive partners (orB, t2, w, topOr, b2, closedChildren, absCycleB, optNumB) are reached through their counterparts inside the factory, so destructuring them at file scope left them unused. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/rtti/data/proof.f.mjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/fjs/rtti/data/proof.f.mjs b/fjs/rtti/data/proof.f.mjs index de44b86b6..b5a4bd690 100644 --- a/fjs/rtti/data/proof.f.mjs +++ b/fjs/rtti/data/proof.f.mjs @@ -175,15 +175,15 @@ const recursiveSchemas = () => { /** @type {_OptNumB} */ const optNumB = () => ['or', optNumA] - return { list, tree, forest, selfOr, orA, orB, outer, inner, t2, x, y, w, topOr, topArr, a2, b2, recordSelf, even, odd, mkRec, anon, closedNode, closedChildren, nestedRest, optList, absCycleA, absCycleB, optNumA, optNumB } + return { list, tree, forest, selfOr, orA, outer, inner, x, y, topArr, a2, recordSelf, even, odd, mkRec, anon, closedNode, nestedRest, optList, absCycleA, optNumA } } const { - list, tree, forest, selfOr, orA, orB, - outer, inner, t2, x, y, w, - topOr, topArr, a2, b2, recordSelf, even, - odd, mkRec, anon, closedNode, closedChildren, nestedRest, - optList, absCycleA, absCycleB, optNumA, optNumB, + list, tree, forest, selfOr, orA, + outer, inner, x, y, topArr, + a2, recordSelf, even, odd, mkRec, + anon, closedNode, nestedRest, optList, absCycleA, + optNumA, } = recursiveSchemas() const tupleNumber = /** @type {const} */ ([number]) From a9209e779cfd66a030064b5429625525587bfedf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:35:03 +0000 Subject: [PATCH 163/370] edag/todo: end chain lambdas by absence (option), not null Files the investigation: the explicit null terminator predates closed-by- default tuples (7852819), whose design (930fa65, #1725) already named option as omission. Records the verified runtime and type-level behavior, the AbsentOr/CheckRaw phantom pattern the recursive lambdas need, the trailing- hole widening, and the migration tasks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/todo/option-terminated-lambdas.md | 148 +++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 fjs/edag/todo/option-terminated-lambdas.md diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md new file mode 100644 index 000000000..a62f48334 --- /dev/null +++ b/fjs/edag/todo/option-terminated-lambdas.md @@ -0,0 +1,148 @@ +# End chain lambdas by absence, not `null` + +**Priority:** P3 +**Status:** open + +## Problem + +The chain grammar spells "the chain ends here" as a literal `null`, in two +roles: as a member of all three lambda unions (`['.', a, 'b', null]` is a +plain read), and as the terminals' stated third operand — `['|()', exp, null]` +in `propertyLambda`, `['|!()', exp, null]` in `optionPropertyLambda`. That +gives `null` double duty in a graph — primitive value *and* chain terminator — +and puts a fourth element on every plain property access, the first cost named +in "The cost" of [`../README.md`](../README.md). + +rtti has a schema built to mean exactly "the member that is not there": +[`option`](../../rtti/module.f.mjs). A continuation is always the last operand +of a closed tuple, which is precisely the position where absence is observable +and where `TupleTs` renders it as an exact optional element. So the chain +could end by the operand *not being there*: `['.', a, 'b']`, `['|()', c]`, +`['|!()', c]`. + +The module's recorded argument against this is stale. "Terminals state their +`null` … were the terminal two elements long, a continuation handed to a +`propertyLambda` slot would be read as the terminal with the rest silently +dropped" (`../module.f.mjs`, `../README.md`) was written when the chain +grammar landed (53077ae, 2026-08-26) — **against open-by-default tuples**, +where it was true. Bare tuples became closed by length one day later +(7852819, 2026-08-27), whose design issue was titled "closed containers by +default, then `option` as omission" (930fa65, #1725) — this issue is the +second half of that plan. Under the closed model a two-element terminal handed +a continuation is rejected by length, not truncated. + +## Investigation (2026-08-28, verified) + +### Runtime, against the real `validate` + +With a `dot` whose fourth position is `or(option, …)` and step schemas ending +by absence: + +| value | result | why | +|---|---|---| +| `['.', a, 'b']` | ok | absent continuation — the plain read | +| `['.', a, 'b', ['\|()', c]]` | ok | two-element terminal step | +| `['.', a, 'b', ['\|()', c, ['\|()', d]]]` | error | the "silent drop" fear: closedness answers by length and **rejects** | +| `['.', a, 'b', undefined]` | error | absence is not a spelling of `undefined` | +| `['.', a, 'b', null]` | error | `null` leaves the chain vocabulary entirely | +| `['.', a, 'b', ['\|()', c], 'junk']` | error | closed node tuple, as today | +| `['.', a, 'b', ['\|?.()', c, ['\|.', 'd']]]` | ok | recursion through the thunks unaffected | + +### Type level, under the repository's flags + +Compiles clean (including `exactOptionalPropertyTypes`), with each negative +row above a genuine type error: + +- Hand-written types use optional trailing tuple elements, which `Ts` renders + **exactly** (`_OptionalTail` in [`../../rtti/ts/proof.f.mjs`](../../rtti/ts/proof.f.mjs)): + + ```ts + type PropertyLambda = + | readonly['|()', Exp] // terminal: genuinely shorter + | readonly['|?.()', Exp, OptionLambda?] + type OptionLambda = + | readonly['|()', Exp, OptionLambda?] + | readonly['|.', Index, OptionPropertyLambda?] + type OptionPropertyLambda = + | readonly['|()', Exp, OptionLambda?] + | readonly['|.', Index, OptionPropertyLambda?] + | readonly['|?.()', Exp, OptionLambda?] + | readonly['|!()', Exp] // terminal + type Dot = readonly['.', Exp, Index, PropertyLambda?] + ``` + +- The recursive thunks' roots now admit absence, so their `Phantom` + annotations must carry the flag in the wrapper — + `Phantom>` — pinned with + `CheckRaw` **in addition to** `Check3`, which cannot see the flag (the + public `Ts` strips absence from both sides). See the `Ts` JSDoc in + [`../../rtti/ts/types.ts`](../../rtti/ts/types.ts). This would be the first + real consumer of the `AbsentOr`/`CheckRaw` pattern — currently documented + with zero users — and it was verified to compile with the mutual recursion + above. `propertyLambda` is not phantom-wrapped, so it needs no wrapper: + `_AdmitsAbsence` walks its `or` directly. + +### What is gained + +- `null` in a graph means one thing again: the primitive value. +- Every plain property access and every chain end drops one element — fewer + elements to store and hash. +- "The chain ends" is spelled as absence, which is what `option` is for. + +### The one widening: trailing holes + +`option` admits a hole as absence, so the sparse `['.', a, 'b', ,]` — length +4, index 3 a hole — **also validates** (verified), a second spelling with a +second hash for the same function, where today's required-`null` schema +rejects every hole. Contained, but real: + +- FunctionalScript cannot produce it: "Two adjacent commas are not an elision: + an array has no holes" ([`../../../spec/README.md`](../../../spec/README.md), + Arrays). Only a host-JS producer can spell it. +- A hole even *evaluates* identically to absence (reading it yields + `undefined`), so the leak is canonicality-only, the same class as the + identity-dependent rules `validate` already leaves to the Stage 2 validator + (see Caveats in [`../README.md`](../README.md)). "No holes" joins that list — + or rtti grows a no-holes rule for validated containers, defensible on its + own since a DJS value is never sparse. + +## Proposal + +Replace the `null` member of all three lambda schemas with `option`, drop the +terminals' third operand, and let the continuation positions of `dot`, +`optionDot`, `optionCall` and the steps end by absence. Code changes are +small; the bulk is mechanical respelling of proofs and prose. + +[amnesia](../amnesia/module.f.mjs) barely changes: its four `k === null` +checks become `k === undefined`, since reading the continuation position of a +shorter tuple yields `undefined` — which the schema guarantees is not a +present value there. + +### Tasks + +- [ ] `../module.f.mjs`: `option` for `null` in the three lambda unions; + terminals become closed 2-tuples; `AbsentOr` phantom annotations plus + `CheckRaw` asserts for `_optionLambda`/`_optionPropertyLambda` +- [ ] `../types.ts`: the optional-element types above +- [ ] `../amnesia/module.f.mjs`: `k === null` → `k === undefined`; signatures + take `… | undefined` +- [ ] `../proof.f.mjs`, `../amnesia/proof.f.mjs`: respell (~200 trailing + `null`s); add rejections for present `null`, present `undefined`, and the + smuggled continuation on a terminal; the `unspellable` family list holds +- [ ] `../README.md`: node and spelling tables; "Terminals state their + `null`" inverts into "closedness by length rejects a smuggled + continuation"; "The cost" shrinks; Caveats gains the trailing-hole note +- [ ] decide where hole rejection lives: an edag caveat deferred to the + Stage 2 validator, or a no-holes rule in rtti + +## Related + +- [`../README.md`](../README.md) — Chains; "Terminals state their `null`"; + The cost +- [`option`](../../rtti/module.f.mjs), + [`AbsentOr`/`CheckRaw`/`TupleTs`](../../rtti/ts/types.ts) — the machinery, + proven in [`../../rtti/ts/proof.f.mjs`](../../rtti/ts/proof.f.mjs) +- 7852819 / 930fa65 (#1725) — closed containers by default, then `option` as + omission; this issue is that plan's second half applied to edag +- [`../../rtti/todo/identity-aware-parse.md`](../../rtti/todo/identity-aware-parse.md) + — the Stage 2 validator the hole check could join From d66ae39eb5fe39c84f747206e5c3622f384d1a6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:35:15 +0000 Subject: [PATCH 164/370] todo: isNotFound is the boundary marker, not part of the move The step-4 note was written before review established that isNotFound belongs in effects/node, and still listed it among the moved names under the claim that none of them names a host. It now records the opposite, and uses it as the test to apply to each operation the remaining move covers rather than moving the list wholesale. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/share-browser-console-runner.md | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 2697d3976..98253e4ae 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -133,16 +133,26 @@ and is reviewable without the next one. **The vocabulary went first, and it was not speculative.** Before an operation can move, the types it is *declared in* have to have a home: `OpResult`, `IoError`, `IoErrorInfo`, `IoChannel`, `IoResult` and the - `ioError`/`toIoError`/`isNotFound` constructors were all in - `effects/node`, and none of them names a host — "the runner cannot - dispatch" and "the host tried and failed" are how *any* operation goes - wrong. That misfiling already had a victim: `effects/memory/types.ts`, - which has no host at all, imported `OpResult` from `../node/types.ts`. - So that move is separation of concerns with a consumer today + `ioError`/`toIoError` constructors were all in `effects/node`, and none + of them names a host — "the runner cannot dispatch" and "the host tried + and failed" are how *any* operation goes wrong. That misfiling already + had a victim: `effects/memory/types.ts`, which has no host at all, + imported `OpResult` from `../node/types.ts`. So that move is separation + of concerns with a consumer today ([DESIGN.md §4](../../../DESIGN.md)), not an extraction on the promise of one — which is the test the operations themselves have yet to pass, and why they wait for step 5. `effects/node` re-exports every moved name, so the several dozen modules that reach for them through it are untouched. + + **`isNotFound` stayed, and it is the boundary marker for this step.** It + reads `ENOENT`, a POSIX filesystem code a browser never reports, so it is + a node predicate however much it looks like the constructors beside it. + Being about a *host failure* does not make a thing host-agnostic; being + about no host in particular does. Apply that test to each operation below + rather than moving the list wholesale — + [node-module-layering](../../effects/todo/node-module-layering.md) is + where those rulings live, and it already declines to move `Now` and + `RandomInt` for a related reason. - [ ] **5. A browser interpreter** for exactly those operations, with no scheduling policy of its own. This is also what earns step 4's *operation* move its second consumer: until a second host implements `now`, `sandbox`, From d6d16b49884fbb4a33f7633c175ba8335c70e4fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:35:38 +0000 Subject: [PATCH 165/370] virtual: read own names in the leaf operations too Refusing the inherited name only in the descent moved `__proto__` from one wrong answer to a worse one: `readFile('__proto__')` reached its is-a-file assertion and threw, outside the effect's channel, and `rm('__proto__')` reported success. Every leaf lookup now goes through `entryOf`, so all twelve agree, and the todo that deferred them is gone because there is nothing left in it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BRNUuhnny5cNz1NFa5KoDV --- changelog/unreleased/1754.md | 5 +- fjs/effects/node/virtual/module.f.mjs | 24 ++++---- fjs/effects/node/virtual/proof.f.mjs | 30 ++++++++++ .../todo/prototype-names-read-as-entries.md | 60 ------------------- 4 files changed, 45 insertions(+), 74 deletions(-) delete mode 100644 fjs/effects/node/virtual/todo/prototype-names-read-as-entries.md diff --git a/changelog/unreleased/1754.md b/changelog/unreleased/1754.md index c752be0b4..a88424b51 100644 --- a/changelog/unreleased/1754.md +++ b/changelog/unreleased/1754.md @@ -1,3 +1,4 @@ - `effects/node/virtual`: a path naming an inherited property (`toString`, - `__proto__`) is absent rather than an entry, in `stat` and in the descent - every operation shares + `__proto__`) is absent in every operation, rather than reading as a + `JsModule` or a directory — `access` and `readFile` claimed such a name + existed, and `readFile` threw outside the effect's channel diff --git a/fjs/effects/node/virtual/module.f.mjs b/fjs/effects/node/virtual/module.f.mjs index cafb475ed..36f95fb6a 100644 --- a/fjs/effects/node/virtual/module.f.mjs +++ b/fjs/effects/node/virtual/module.f.mjs @@ -148,7 +148,7 @@ const enotdir = error(ioError({ code: 'ENOTDIR', message: 'not a directory' })) /** @type {(path: string) => (state: State) => readonly [State, IoResult]} */ const readFile = readOperation((dir, path) => { if (path.length !== 1) { return enoent } - const file = dir[path[0]] + const file = entryOf(dir, path[0]) if (file === undefined) { return enoent } if (isJsModule(file)) { throw new Error(`'${path[0]}' is a JsModule; readFile not supported`) } // `operation`'s wrapper descends into every plain-object (`Dir`) entry @@ -172,7 +172,7 @@ const readFile = readOperation((dir, path) => { /** @type {(path: string) => (state: State) => readonly [State, IoResult]} */ const import_ = readOperation((dir, path) => { if (path.length !== 1) { return fail('no such file') } - const entry = dir[path[0]] + const entry = entryOf(dir, path[0]) if (entry === undefined || !isJsModule(entry)) { return fail(`'${path[0]}' is not a JsModule`) } return ok(entry()) }) @@ -183,7 +183,7 @@ const writeFileError = fail('invalid file') const writeFileOp = payload => (dir, path) => { if (path.length !== 1) { return [dir, writeFileError] } const [name] = path - const file = dir[name] + const file = entryOf(dir, name) if (file !== undefined && !isBinFile(file)) { return [dir, writeFileError] } dir = { ...dir, [name]: [payload] } return [dir, okVoid] @@ -220,14 +220,14 @@ const readdir = (base, recursive) => readOperation((dir, path) => { const access = readOperation((dir, path) => { if (path.length === 0) { return okVoid } if (path.length !== 1) { return enoent } - return dir[path[0]] !== undefined ? okVoid : enoent + return entryOf(dir, path[0]) !== undefined ? okVoid : enoent }) /** @type {(dir: Dir, path: readonly string[]) => readonly [Dir, IoResult]} */ const rmOp = (dir, path) => { if (path.length !== 1) { return [dir, fail('invalid path')] } const [name] = path - const entry = dir[name] + const entry = entryOf(dir, name) if (entry === undefined) { return [dir, fail('no such file')] } // No "is a directory" guard here: `operation`'s wrapper descends into // every plain-object (`Dir`) entry before this op ever runs, so `entry` @@ -246,13 +246,13 @@ const extractEntity = (dir, path) => { if (path.length === 0) { return [dir, fail('cannot extract root')] } if (path.length === 1) { const [name] = path - const entry = dir[name] + const entry = entryOf(dir, name) if (entry === undefined) { return [dir, enoent] } const { [name]: _, ...rest } = dir return [rest, ok(entry)] } const [first, ...rest] = path - const sub = dir[first] + const sub = entryOf(dir, first) if (sub === undefined || !isDir(sub)) { return [dir, enoent] } const [newSub, result] = extractEntity(sub, rest) if (result[0] === 'error') { return [dir, result] } @@ -270,7 +270,7 @@ const insertEntityAt = (dir, path, entity) => { assert(path.length > 0, 'cannot insert at root') if (path.length === 1) { const [name] = path - const existing = dir[name] + const existing = entryOf(dir, name) if (existing !== undefined) { const entityIsDir = isDir(entity) const existingIsDir = isDir(existing) @@ -291,7 +291,7 @@ const insertEntityAt = (dir, path, entity) => { return [{ ...dir, [name]: entity }, okVoid] } const [first, ...rest] = path - const sub = dir[first] + const sub = entryOf(dir, first) if (sub === undefined) { return [dir, enoent] } if (!isDir(sub)) { return [dir, fail('not a directory')] } const [newSub, result] = insertEntityAt(sub, rest, entity) @@ -319,7 +319,7 @@ const rename = (src, dst) => state => { /** @type {(path: string, offset: number, size: number) => (state: State) => readonly [State, IoResult]} */ const readBytesOp = (path, offset, size) => readOperation((dir, p) => { if (p.length !== 1) { return enoent } - const file = dir[p[0]] + const file = entryOf(dir, p[0]) if (file === undefined) { return enoent } if (isJsModule(file)) { throw new Error(`'${p[0]}' is a JsModule; readBytes not supported`) } // `operation`'s wrapper descends into every plain-object (`Dir`) entry @@ -379,7 +379,7 @@ const createExclusiveOp = (dir, path) => { if (path.length !== 1) { return [dir, invalidPath] } const [name] = path // O_EXCL: fail if the name is already taken; otherwise create an empty file. - if (dir[name] !== undefined) { return [dir, eexist] } + if (entryOf(dir, name) !== undefined) { return [dir, eexist] } return [{ ...dir, [name]: [] }, okVoid] } @@ -395,7 +395,7 @@ const createExclusive = operation(createExclusiveOp) const writeBytesRawOp = (offset, data) => (dir, p) => { if (p.length !== 1) { return [dir, enoent] } const [name] = p - const file = dir[name] + const file = entryOf(dir, name) if (file === undefined) { return [dir, enoent] } // writeBytes never creates if (!isBinFile(file)) { return [dir, fail(`'${name}' is not a file`)] } if (!Number.isInteger(offset) || offset < 0) { return [dir, fail(`Offset ${offset} is invalid`)] } diff --git a/fjs/effects/node/virtual/proof.f.mjs b/fjs/effects/node/virtual/proof.f.mjs index 2488e274f..be6fc63dc 100644 --- a/fjs/effects/node/virtual/proof.f.mjs +++ b/fjs/effects/node/virtual/proof.f.mjs @@ -485,6 +485,36 @@ export const proof = { absent('__proto__') absent('__proto__/x') }, + // Every operation asks the same question, so every operation answers the + // same way. Stopping at `stat` is what made `__proto__` worse rather than + // better: with the descent refusing it and the leaf still reading it, + // `readFile` reached its is-a-file assertion and *threw* — out of the + // effect's channel, where no FunctionalScript program can answer it — and + // `rm` reported success for a name that was never there. + inheritedNameInEveryOperation: () => { + /** @type {(e: Effect) => IoChannel} */ + const failure = e => { + const [, result] = virtual(emptyState)(e) + assert(result[0] === 'error', result) + return result[1] + } + for (const name of ['__proto__', 'toString']) { + assertIoCode(failure(stat(name)), 'ENOENT') + assertIoCode(failure(readFile(name)), 'ENOENT') + assertIoCode(failure(access(name)), 'ENOENT') + // `rm` words a missing entry its own way, and says it here too. + assertIoMessage(failure(rm(name)), 'no such file') + // A name that is not a `JsModule`, because it is not an entry. + assertIoMessage(failure(import_(name)), `'${name}' is not a JsModule`) + } + // And an own name still works, so the guard refuses names rather than + // lookups: `createExclusive` claims one, and the second try is `EEXIST`. + const [claimed] = virtual(emptyState)(createExclusive('__proto__')) + assertEq(Object.keys(claimed.root).join(), '__proto__') + const [, again] = virtual(claimed)(createExclusive('__proto__')) + assert(again[0] === 'error', again) + assertIoCode(again[1], 'EEXIST') + }, statOnRegularFile: () => { /** @type {Dir} */ const root = { 'a.txt': [vec8(0x41n)] } diff --git a/fjs/effects/node/virtual/todo/prototype-names-read-as-entries.md b/fjs/effects/node/virtual/todo/prototype-names-read-as-entries.md deleted file mode 100644 index d68da41f4..000000000 --- a/fjs/effects/node/virtual/todo/prototype-names-read-as-entries.md +++ /dev/null @@ -1,60 +0,0 @@ -## Inherited names read as entries in every operation but `stat` - -**Priority:** P3 -**Status:** open - -### Problem - -A `Dir` is a plain object, so `dir[name]` finds whatever `Object.prototype` -holds. `dir['toString']` is a function, which this file system reads as a -`JsModule`; `dir['__proto__']` is an object, which it reads as a directory. A -host has no such names, so every answer built from one models nothing. - -`operation`'s descent and `statPath` now read **own** names only, through -`entryOf` in [`../module.f.mjs`](../module.f.mjs) — added with the `ENOTDIR` -mapping, because reading an inherited name there answered `ENOTDIR` ("the name -before this one exists") for `toString/x` under an empty root, where a host says -`ENOENT`. Every *other* operation still does its own lookup and still reads -through the prototype. Measured against `emptyState` after that fix: - -| call | today | a host | -|---|---|---| -| `access('toString')` | `ok` — the path exists | `ENOENT` | -| `readFile('toString')` | throws `'toString' is a JsModule; readFile not supported` | `ENOENT` | -| `stat('toString')` | `ENOENT` | `ENOENT` | - -The `readFile` row is the sharp one: a `throw` is not in the effect's channel at -all, so a program cannot answer it — and FunctionalScript has no `try`/`catch` -to contain it. - -Nothing reaches this from FunctionalScript, where a `Dir` is a fixture the proof -author wrote: it takes a path naming an inherited property, which is why this is -filed rather than fixed under the pull request that found it -([#1751](https://github.com/functionalscript/functionalscript/pull/1751), where -the Codex review bot raised it). - -### Proposal - -Read every entry through `entryOf`. It is already there and already documents -why; what is left is the call sites, each a one-expression change: - -`readFile`, `import_`, `writeFileOp`, `access`, `rmOp`, `extractEntity`, -`createExclusiveOp`, `writeBytesRawOp`, and `readBytes`'s own lookup. - -`readdir` needs nothing — it walks `Object.entries`, which is own-only already, -and is the shape the others should be measured against. - -### Tasks - -- [ ] Route every entry lookup in `../module.f.mjs` through `entryOf`. -- [ ] Pin `access` and `readFile` against an inherited name from an empty root, - beside `statOnInheritedName` in [`../proof.f.mjs`](../proof.f.mjs). -- [ ] Check whether any fixture in the repository relies on the current - reading — none is expected, since it takes a deliberately chosen name. - -### Related - -- [`../module.f.mjs`](../module.f.mjs) — `entryOf`, the fix, and the reason it - exists. -- `fjs/effects/module.f.mjs` — its own note on a record whose keys reach - `Object.prototype`, the same hazard in the memory runner's store. From f80ca12ca522715874ce54c6a5b6e7dec057ffc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:40:43 +0000 Subject: [PATCH 166/370] =?UTF-8?q?todo:=20now,=20fetch=20and=20import=20a?= =?UTF-8?q?re=20unsettled=20=E2=80=94=20say=20so=20in=20both=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-reference added in the last commit made node-module-layering.md read as the source of truth, but the two files disagree: it keeps Now, Fetch and Import in effects/node on a reader-benefit argument, while share-browser-console-runner's step 4 lists all three as moving. Whichever a later reader opened first would have looked authoritative. Neither was written knowing the fact that decides it — which operations the step-5 browser interpreter actually implements — so both now record the disagreement, name that as what settles it, and require step 5 to update both in one change. The expectation, not a ruling: now and import move (a browser proof run needs a clock and dynamic import), fetch stays (nothing in the shared runner performs one). all, await and sandbox were never in dispute; both files move them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/node-module-layering.md | 26 ++++++++++++++--- .../todo/share-browser-console-runner.md | 29 ++++++++++++++++--- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index 414f578fb..81acbb6f2 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -54,7 +54,8 @@ provides*. Proposed destinations: | `fjs/effects/sandbox/module.f.mjs` | `Sandbox`, `SandboxResult`, `sandbox`, `Await`, `awaitIfPromise` — the "run foreign code and observe what happened" pair | | `fjs/effects/console/module.f.mjs` | `Read`, `Write`, `ReadConsoles`, `WriteConsoles`, `Console`, `log`, `error`, `readLine`, `errorExit`, and a **new named `Std`** (see below) | | `fjs/effects/test/module.f.mjs` | `Test`, `TestFn`, `TestContext`, `test` — registration with an external framework, not I/O | -| stays in `fjs/effects/node` | `Fs` and its members, `Http`, `Fetch`, `Import`, `Forever`, `Now`, `RandomInt`, `isNotFound`, `Env`, `Engine`, `NodeOp`, `NodeProgramOptions`, `Program`, `NodeProgram`, `NodeOperationMap` | +| stays in `fjs/effects/node` | `Fs` and its members, `Http`, `Forever`, `RandomInt`, `isNotFound`, `Env`, `Engine`, `NodeOp`, `NodeProgramOptions`, `Program`, `NodeProgram`, `NodeOperationMap` | +| unsettled | `Now`, `Fetch`, `Import` — this issue and share-browser-console-runner's step 4 disagree; step 5 decides (see the judgement call below) | | already moved to `fjs/effects` | `OpResult`, `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult`, `ioError`, `toIoError` — the vocabulary every operation is declared in; `effects/node` re-exports them (see the judgement call below) | `NodeOp` stays where it is and keeps unioning every family — it is the @@ -65,9 +66,26 @@ union that names them all does not. Judgement calls worth deciding explicitly rather than by accident: -- **`Now` / `RandomInt` stay.** They are ambient host capabilities with no - cross-runtime abstraction to gain, and no consumer outside `fjs/cas` and the - interpreters. Moving them would be motion without a reader benefit. +- **`RandomInt` stays.** An ambient host capability with no cross-runtime + abstraction to gain and no consumer outside `fjs/cas` and the interpreters. + Moving it would be motion without a reader benefit. +- **`Now`, `Fetch` and `Import` are unsettled, and step 5 of + [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) + decides them.** This issue put all three in the "stays" row on the reader-benefit + argument above; that issue's step 4 lists `now`, `fetch` and `import` among the + operations to move. Both were written without the fact that settles it — **which + operations a browser interpreter actually implements** — so neither ruling is + authoritative and the disagreement is recorded here rather than resolved by + whichever file a later reader opens first. + + The test to apply is the one `isNotFound` failed: not "does a browser also + have one of these", but "is this operation about no host in particular". By + that test `now` and `import` look likely to move — a browser proof run needs a + clock and dynamic import, so step 5 gives them a second implementer — and + `fetch` looks likely to stay, since nothing in the shared runner performs one + and DESIGN.md §4 extracts at the second *real* consumer, not the second + possible one. Those are expectations, not rulings: whichever way step 5 goes, + it updates both files in the same change. - **`isNotFound` stays, and this was tested.** It encodes `ENOENT` specifically — a POSIX filesystem code that a host without a filesystem never reports — so it *is* a Node-layer concern. A change that moved it to the core diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 98253e4ae..1f6691eba 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -126,9 +126,22 @@ and is reviewable without the next one. rule they rest on is in [browser testing](browser-testing.md). - [ ] **4. Common effects.** Move the host-independent operations (`all`, - `await`, `fetch`, `import`, `now`, `sandbox`) out of `effects/node` into a - shared module that `effects/node` re-exports unchanged, so nothing has to - move with them. + `await`, `sandbox`, and whichever of `now`, `fetch` and `import` survive + the test below) out of `effects/node` into a shared module that + `effects/node` re-exports unchanged, so nothing has to move with them. + + **Three of that list are unsettled, and this step does not get to assume + them.** `all`, `await` and `sandbox` are agreed: + [node-module-layering](../../effects/todo/node-module-layering.md) moves + them too. But that issue keeps `Now`, `Fetch` and `Import` in + `effects/node` on a reader-benefit argument, and this step was written + listing all three as moving. Neither was written knowing the fact that + decides it — which operations the step-5 interpreter actually implements — + so step 5 settles them and updates both files in the same change. The + expectation recorded there: `now` and `import` move (a browser proof run + needs a clock and dynamic import), `fetch` stays (nothing in the shared + runner performs one, and DESIGN.md §4 extracts at the second *real* + consumer). **The vocabulary went first, and it was not speculative.** Before an operation can move, the types it is *declared in* have to have a home: @@ -155,11 +168,19 @@ and is reviewable without the next one. `RandomInt` for a related reason. - [ ] **5. A browser interpreter** for exactly those operations, with no scheduling policy of its own. This is also what earns step 4's *operation* - move its second consumer: until a second host implements `now`, `sandbox`, + move its second consumer: until a second host implements `sandbox`, `await` and `all`, moving them out of `effects/node` makes nothing shorter or clearer, and DESIGN.md §4 says to extract at the second real consumer rather than before it. The two are therefore one design in two commits, not one step deferred. + + **Its operation set is also the ruling** on `now`, `fetch` and `import`, + which step 4 and + [node-module-layering](../../effects/todo/node-module-layering.md) + currently disagree about. What this interpreter implements is what has a + second consumer; what it does not implement stays in `effects/node` until + something needs it. Write the answer into both files in this step's own + change, so neither is left asserting what the other denies. - [x] **6. One reporter.** The event stream — a leaf landed, a run ended — that both hosts subscribe to. Step 2 gave them the *value*; this gave them the seam it travels through. `Reporter.result` now receives the From 59e4b84a67a3d02c8b8178b5b73064cbf1088c20 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:41:57 +0000 Subject: [PATCH 167/370] docs: repair broken links and normalize todo issue headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sweep over the ~600 markdown files in the tree for broken links, missing issue metadata, and heading levels that disagree with todo/README.md's format. Broken links (7 targets, 8 sites). `fjs/cli` and `fjs/types/prime_field` had never-existing or since-moved README targets; `patricia_trie` moved under `fjs/types/`; the `rtti` README's `#closed-containers` anchor is now `#structs-and-tuples-are-closed`. Three citations pointed at todo files deleted when their work shipped — `dispatch-help-rendering` (`renderHelp`, e1f5e44) and `name-entity-kind-discrimination-once` (`isBinFile`/`isJsModule`/`isDir`, 0c21ef0) — and are rewritten in the `retired` form todo/README.md documents, naming the code that shipped. The rotted `#L63` line anchor on `reciprocal` is dropped rather than re-pinned, per the tokenizer-line-citations issue. Root README's CLI table was missing `fjs web` and `fjs help`, both of which fjs/README.md and the dispatch table in fjs/module.f.mjs carry. Reordered to match the command table itself, and `compile` now links `fjs/fsc` alongside `fjs/djs` as fjs/README.md does. Issue metadata. Four issue files carried no `**Priority:**`/`**Status:**` header, and two of those spelled it as a list item; all four now use the documented bare form. `abstract-write` is P4 as a pure DRY refactor, the rest take the documented P3 default — adjust if that misreads them. Heading levels. 56 issue files titled `# Title` where todo/README.md's format is `## Title`. Where sections were already `###` the title alone is demoted; where the whole document sat one level up, every heading is demoted together so sections stay below the title. GitHub anchors are level-independent, so no inbound link changes. `spec/todo/` and `todo/plan/` are excluded throughout: they hold specification drafts and planning documents, not issues, and their own READMEs describe them that way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- README.md | 6 ++- fjs/README.md | 2 +- fjs/cas/todo/abstract-write.md | 5 +- fjs/cas/todo/list-unshard.md | 10 ++-- fjs/cli/todo/options-edsl.md | 5 +- fjs/crypto/secp/README.md | 2 +- .../todo/generic-operation-payload-erasure.md | 2 +- .../node/todo/write-from-stream-finalize.md | 5 +- .../node/virtual/todo/dir-spine-descend.md | 5 +- .../node/virtual/todo/resolve-file-helper.md | 13 ++--- .../todo/do-generic-operation-signatures.md | 2 +- .../todo/step-continuation-operation-union.md | 2 +- .../todo/mockrun-parameters-inference.md | 2 +- fjs/js/todo/token-kind-narrowing.md | 2 +- fjs/mcp/todo/remote-url.md | 5 +- .../json/todo/stringify-sorted-canonical.md | 10 ++-- fjs/media/note/todo/extend-note-format.md | 2 +- .../todo/effectful-dispatch-skeleton.md | 10 ++-- fjs/protocol/mcp/todo/output-type.md | 6 +-- fjs/rtti/todo/checked-const-pin.md | 10 ++-- fjs/rtti/todo/excluded-string-values.md | 10 ++-- .../hostile-accessor-hermetic-read-path.md | 6 +-- fjs/rtti/todo/identity-aware-parse.md | 14 +++--- fjs/rtti/todo/prefix-then-rest-tuple.md | 8 +-- fjs/rtti/todo/proof-shared-asserts.md | 10 ++-- fjs/rtti/todo/schema-walk-own-indices.md | 10 ++-- .../todo/undeclared-members-declared-scan.md | 6 +-- fjs/sul/level/hash/README.md | 2 +- fjs/types/btree/todo/find-path-item-typing.md | 2 +- nanvm-lib/todo/string-debug-placement.md | 10 ++-- todo/037-language-design-map.md | 2 +- todo/044-error-handling-pattern.md | 2 +- todo/088-python.md | 6 +-- todo/116-tsgo-regression.md | 2 +- todo/123-tsgo-types-node.md | 2 +- todo/134-nominal-types-proposal.md | 2 +- todo/144-ts-prototype-functions.md | 2 +- todo/blocked/automatic-method-binding.md | 2 +- todo/blocked/bigint-bit-len.md | 2 +- .../blocked/bun-optional-chain-parentheses.md | 2 +- todo/blocked/integer-as-bigint.md | 2 +- todo/blocked/js-extension-type-annotations.md | 2 +- .../jsdoc-typedef-doc-declaration-emit.md | 14 +++--- todo/blocked/jsdoc-typedef-strip-internal.md | 2 +- todo/blocked/lexicographic-integer-keys.md | 2 +- todo/blocked/pipeline-operator.md | 2 +- todo/blocked/undefined-removes-property.md | 2 +- todo/blocked/utf8-strings.md | 2 +- todo/blocked/wasmtime-threads.md | 2 +- todo/camap.md | 2 +- todo/changelog-from-git-history.md | 8 +-- todo/changelog-website.md | 10 ++-- todo/commit-message-enforcement.md | 10 ++-- todo/commit-message-standard.md | 18 +++---- todo/edag-stage1-discussion.md | 50 +++++++++---------- todo/eslint.md | 2 +- todo/flow.md | 22 ++++---- todo/inline-type-casts.md | 2 +- todo/new-pl.md | 48 +++++++++--------- todo/rtti-type-system.md | 2 +- todo/strict-static-analysis.md | 2 +- todo/tsconfig-strict-flags.md | 2 +- todo/types-for-fs.md | 6 +-- 63 files changed, 217 insertions(+), 205 deletions(-) diff --git a/README.md b/README.md index fdc65a4c1..f25cb7069 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,13 @@ data expressions (objects, arrays, strings, numbers, `bigint`, booleans, `null`, | Command | Description | Documentation | |---------------|----------------------------------------------------------------|--------------------------------------------------------| | `fjs test` | Run the FunctionalScript test suite | [fjs/emergent_testing](fjs/emergent_testing/README.md) | -| `fjs compile` | Compile a FunctionalScript module to JavaScript or JSON | [fjs/djs](fjs/djs/README.md) | -| `fjs run` | Run a FunctionalScript module as a Node program | [fjs/README.md](fjs/README.md) | +| `fjs compile` | Compile a FunctionalScript module to JavaScript or JSON | [fjs/djs](fjs/djs/README.md), [fjs/fsc](fjs/fsc/README.md) | | `fjs cas` | Content-addressable storage (`add`, `get`, `list`) | [fjs/cas/README.md](fjs/cas/README.md) | | `fjs mcp` | [MCP](https://modelcontextprotocol.io/) server over stdio, exposing the CAS and Evo as tools | [fjs/mcp/README.md](fjs/mcp/README.md) | | `fjs ci` | Generate the GitHub Actions CI workflow | [fjs/ci/README.md](fjs/ci/README.md) | +| `fjs web` | Serve a directory over HTTP | [fjs/web/README.md](fjs/web/README.md) | +| `fjs run` | Run a FunctionalScript module as a Node program | [fjs/README.md](fjs/README.md) | +| `fjs help` | Print the available commands | [fjs/README.md](fjs/README.md) | Run `fjs help` to print the available commands, or see [fjs/README.md](fjs/README.md) for the full CLI reference. Commands also accept diff --git a/fjs/README.md b/fjs/README.md index 7d999b5eb..d5693cc54 100644 --- a/fjs/README.md +++ b/fjs/README.md @@ -71,7 +71,7 @@ request path to a file under it — enough to open the pages this repository generates in a browser, where a `file://` URL has no origin. It binds loopback, so what it serves stays on the machine it runs on. Both arguments are positional; `port` becomes `--port`, and `--host` becomes possible at all, once -[`fjs/cli`](cli/README.md) has named options. +[`fjs/cli`](cli/module.f.mjs) has named options. ``` fjs web # serve the working directory on http://127.0.0.1:8080/ diff --git a/fjs/cas/todo/abstract-write.md b/fjs/cas/todo/abstract-write.md index 5c687d07e..07a6f8ddf 100644 --- a/fjs/cas/todo/abstract-write.md +++ b/fjs/cas/todo/abstract-write.md @@ -1,4 +1,7 @@ -# Abstract Write +## Abstract Write + +**Priority:** P4 +**Status:** open Functions `writeFromStream` and a part of `FileCas.write` are very similar. The main difference is that the `loop` function from `FileCas.write` also computes a hash. diff --git a/fjs/cas/todo/list-unshard.md b/fjs/cas/todo/list-unshard.md index 101f730cf..4b1938347 100644 --- a/fjs/cas/todo/list-unshard.md +++ b/fjs/cas/todo/list-unshard.md @@ -1,9 +1,9 @@ -# `fileCas.list` re-implements the inverse of `toPath` +## `fileCas.list` re-implements the inverse of `toPath` **Priority:** P4 **Status:** open -## Problem +### Problem The shard layout's forward direction is owned by `toPath` (`fjs/cas/module.f.mjs:59-64`): a cBase32 key string splits `2 / 2 / rest` @@ -27,7 +27,7 @@ different shard depth) would silently produce wrong keys in `list` — the same one-layout-two-owners hazard that `shard-dir-helper.md` records for the forward direction in `publish`. -## Proposal +### Proposal Give the layout a single owner in both directions. Alongside the `shard` helper proposed in `shard-dir-helper.md` (forward: key → `{dir, name}`), add @@ -45,7 +45,7 @@ prefix and separators inline. The only call site touched is `fileCas.list`. Together with `shard-dir-helper.md`, the `2/2/rest` rule then exists exactly once, with `toPath`/`unshard` as its two views. -## Tasks +### Tasks - [ ] Extract the path→key derivation from `fileCas.list` into a named inverse helper co-located with `toPath` (and `shard`, if @@ -53,7 +53,7 @@ once, with `toPath`/`unshard` as its two views. - [ ] Keep the ENOENT-is-empty-store behavior of `list` unchanged. - [ ] Run `npx tsc` and `fjs t`; CAS proofs pass unchanged. -## Related +### Related - `fjs/cas/todo/shard-dir-helper.md` — the forward half of the same layout concern; these two issues are complementary and should cross-reference. diff --git a/fjs/cli/todo/options-edsl.md b/fjs/cli/todo/options-edsl.md index 88f9b54b4..97b4e23e6 100644 --- a/fjs/cli/todo/options-edsl.md +++ b/fjs/cli/todo/options-edsl.md @@ -68,8 +68,9 @@ Design first, then migrate. The design should settle: - [positional-arity-check](./positional-arity-check.md) — subsumed by declared positional parameters. -- [dispatch-help-rendering](./dispatch-help-rendering.md) — the help - rendering that declared options must plug into. +- dispatch-help-rendering (retired; shipped as `renderHelp` in + [`../module.f.mjs`](../module.f.mjs)) — the help rendering that declared + options must plug into. - `fjs/todo/66g-fjs-run-commands.md` — the `Commands` reshaping to coordinate with. - [fjs/cas 66g-cas-get-verify-option](../../cas/todo/66g-cas-get-verify-option.md) diff --git a/fjs/crypto/secp/README.md b/fjs/crypto/secp/README.md index bf71a241b..273580e78 100644 --- a/fjs/crypto/secp/README.md +++ b/fjs/crypto/secp/README.md @@ -18,7 +18,7 @@ Operation mapping: |find `n` for specific `a` and `b`, such that `a * n = b` |`n = log(a, a ^ n)` | |for any `n` there is `k = 1/n` such that `a * n * k = a` |`a ^ n ^ k = a` | -The scalar multiplier `n` is defined on a prime `N` field. We can `k` such that `(k * n)%N = 1`. The function that returns `1/n` is called `reciprocal`, see [../prime_field/module.f.mjs](../prime_field/module.f.mjs#L63) and it uses [Euclidean division](https://en.wikipedia.org/wiki/Euclidean_division). +The scalar multiplier `n` is defined on a prime `N` field. We can `k` such that `(k * n)%N = 1`. The function that returns `1/n` is called `reciprocal`, see [`fjs/types/prime_field`](../../types/prime_field/module.f.mjs) and it uses [Euclidean division](https://en.wikipedia.org/wiki/Euclidean_division). `G` is a base point on the elliptic curve. It has `x` and `y`, or a compressed representation has `x` and a boolean. diff --git a/fjs/effects/node/todo/generic-operation-payload-erasure.md b/fjs/effects/node/todo/generic-operation-payload-erasure.md index 8e0ba9bb6..54fff41f1 100644 --- a/fjs/effects/node/todo/generic-operation-payload-erasure.md +++ b/fjs/effects/node/todo/generic-operation-payload-erasure.md @@ -1,4 +1,4 @@ -# `Pr` erases a generic operation's type parameter +## `Pr` erases a generic operation's type parameter **Priority:** P3 **Status:** open diff --git a/fjs/effects/node/todo/write-from-stream-finalize.md b/fjs/effects/node/todo/write-from-stream-finalize.md index 4930875db..9ed3f1be2 100644 --- a/fjs/effects/node/todo/write-from-stream-finalize.md +++ b/fjs/effects/node/todo/write-from-stream-finalize.md @@ -1,3 +1,6 @@ -# writeFromStream should unlink a file in case of error +## writeFromStream should unlink a file in case of error + +**Priority:** P3 +**Status:** open Currently, the `writeFromStream` doesn't delete a file in case of an error and leave partial file. diff --git a/fjs/effects/node/virtual/todo/dir-spine-descend.md b/fjs/effects/node/virtual/todo/dir-spine-descend.md index 6d4dab273..0b56c1729 100644 --- a/fjs/effects/node/virtual/todo/dir-spine-descend.md +++ b/fjs/effects/node/virtual/todo/dir-spine-descend.md @@ -82,5 +82,6 @@ as-is. ### Related - [resolve-file-helper](./resolve-file-helper.md) — leaf-level counterpart. -- [name-entity-kind-discrimination-once](./name-entity-kind-discrimination-once.md) - — the guards above should use those predicates once extracted. +- name-entity-kind-discrimination-once (retired; shipped as `isBinFile`, + `isJsModule`, and `isDir` in [`../module.f.mjs`](../module.f.mjs)) — the + guards above should use those predicates. diff --git a/fjs/effects/node/virtual/todo/resolve-file-helper.md b/fjs/effects/node/virtual/todo/resolve-file-helper.md index 2dbdf7bb5..3c1d9c3ed 100644 --- a/fjs/effects/node/virtual/todo/resolve-file-helper.md +++ b/fjs/effects/node/virtual/todo/resolve-file-helper.md @@ -29,10 +29,10 @@ treats `as` as a last resort; four copies of one is worse than none). `statOp` and `writeBytesOp` omit the JsModule throw, so the copies have already drifted slightly. -This is distinct from -[name-entity-kind-discrimination-once](./name-entity-kind-discrimination-once.md): -that issue extracts low-level *type predicates* (`isBinFile`/`isJsModule`/ -`isDir`); even after it lands, each site still repeats this mid-level +This is distinct from name-entity-kind-discrimination-once (retired; shipped +as `isBinFile`, `isJsModule`, and `isDir` in +[`../module.f.mjs`](../module.f.mjs)): that issue extracted low-level *type +predicates*; even with them landed, each site still repeats this mid-level guard-and-select block. ### Proposal @@ -71,7 +71,8 @@ casts disappear with it. ### Related -- [name-entity-kind-discrimination-once](./name-entity-kind-discrimination-once.md) - — lower-level predicates; composes with this. +- name-entity-kind-discrimination-once (retired; shipped as `isBinFile`, + `isJsModule`, and `isDir` in [`../module.f.mjs`](../module.f.mjs)) — + lower-level predicates; composes with this. - [dir-spine-descend](./dir-spine-descend.md) — the leaf functions there would call this resolver. diff --git a/fjs/effects/todo/do-generic-operation-signatures.md b/fjs/effects/todo/do-generic-operation-signatures.md index 0e7426102..e9c1012a6 100644 --- a/fjs/effects/todo/do-generic-operation-signatures.md +++ b/fjs/effects/todo/do-generic-operation-signatures.md @@ -1,4 +1,4 @@ -# `do_` cannot express a generic operation signature +## `do_` cannot express a generic operation signature **Priority:** P3 **Status:** open diff --git a/fjs/effects/todo/step-continuation-operation-union.md b/fjs/effects/todo/step-continuation-operation-union.md index d3e95f9ea..9d1ff234b 100644 --- a/fjs/effects/todo/step-continuation-operation-union.md +++ b/fjs/effects/todo/step-continuation-operation-union.md @@ -1,4 +1,4 @@ -# `step` continuations widen their operation union with a cast +## `step` continuations widen their operation union with a cast **Priority:** P3 **Status:** open diff --git a/fjs/emergent_testing/todo/mockrun-parameters-inference.md b/fjs/emergent_testing/todo/mockrun-parameters-inference.md index bfe447498..2f67adcf0 100644 --- a/fjs/emergent_testing/todo/mockrun-parameters-inference.md +++ b/fjs/emergent_testing/todo/mockrun-parameters-inference.md @@ -1,4 +1,4 @@ -# `mockRun`'s operation map needs `Parameters>` to type-check +## `mockRun`'s operation map needs `Parameters>` to type-check **Priority:** P4 **Status:** open diff --git a/fjs/js/todo/token-kind-narrowing.md b/fjs/js/todo/token-kind-narrowing.md index 589dcae0f..c9a66a5b8 100644 --- a/fjs/js/todo/token-kind-narrowing.md +++ b/fjs/js/todo/token-kind-narrowing.md @@ -1,4 +1,4 @@ -# A token built from a `string` kind needs a cast to become a `JsToken` +## A token built from a `string` kind needs a cast to become a `JsToken` **Priority:** P3 **Status:** open diff --git a/fjs/mcp/todo/remote-url.md b/fjs/mcp/todo/remote-url.md index e88f910be..666542c76 100644 --- a/fjs/mcp/todo/remote-url.md +++ b/fjs/mcp/todo/remote-url.md @@ -1,6 +1,7 @@ -# Remote MCP Server URLs +## Remote MCP Server URLs -- **Status:** open +**Priority:** P3 +**Status:** open A remote URL server should be provided with URL translation function instead of returning a URL from `FileCas`. diff --git a/fjs/media/json/todo/stringify-sorted-canonical.md b/fjs/media/json/todo/stringify-sorted-canonical.md index 11a0e3a23..372aea79c 100644 --- a/fjs/media/json/todo/stringify-sorted-canonical.md +++ b/fjs/media/json/todo/stringify-sorted-canonical.md @@ -1,9 +1,9 @@ -# Canonical `stringifySorted` export +## Canonical `stringifySorted` export **Priority:** P4 **Status:** open -## Problem +### Problem The composition `stringify(sort)` — serialize JSON with object keys sorted, the repo's canonical order-independent serialization — is re-derived and @@ -35,7 +35,7 @@ single discoverable definition. Readers meeting `jsonStr` in one proof and a future change to the canonical form (e.g. a different key ordering) has no single point of definition. -## Proposal +### Proposal Export the composition once from `fjs/media/json/module.f.mjs`, which already imports from `fjs/types/object` (so the `sort` dependency adds nothing new): @@ -52,7 +52,7 @@ inside. Hoisting the composition to module scope at each consumer also aligns with the `AGENTS.md` rule on binding call-invariant partial applications once. -## Tasks +### Tasks - [ ] Add `stringifySorted` to `fjs/media/json/module.f.mjs` with proof coverage in `fjs/media/json/proof.f.mjs` (which itself calls @@ -61,7 +61,7 @@ applications once. `fjs/djs/module.f.mjs`), then the proof files. - [ ] Run `npx tsc` and `fjs t`. -## Related +### Related - `fjs/media/json/serializer/module.f.mjs` — `colon` is exported there and shared with the djs serializer; that deduplication was separate from this diff --git a/fjs/media/note/todo/extend-note-format.md b/fjs/media/note/todo/extend-note-format.md index c64c06293..e94de0bac 100644 --- a/fjs/media/note/todo/extend-note-format.md +++ b/fjs/media/note/todo/extend-note-format.md @@ -1,4 +1,4 @@ -# Extend the note format +## Extend the note format **Priority:** P3 **Status:** open diff --git a/fjs/protocol/json_rpc/todo/effectful-dispatch-skeleton.md b/fjs/protocol/json_rpc/todo/effectful-dispatch-skeleton.md index 1e5766fc7..b54b6c958 100644 --- a/fjs/protocol/json_rpc/todo/effectful-dispatch-skeleton.md +++ b/fjs/protocol/json_rpc/todo/effectful-dispatch-skeleton.md @@ -1,9 +1,9 @@ -# Envelope routing skeleton shared by pure and effectful dispatch +## Envelope routing skeleton shared by pure and effectful dispatch **Priority:** P4 **Status:** open -## Problem +### Problem The JSON-RPC request preamble — decode the envelope, answer a malformed one with `Invalid Request` (`id: null`), and split notifications @@ -37,7 +37,7 @@ if (id === undefined) { and stateful — so the envelope routing, which is `json/rpc`'s concern, is re-derived downstream. `decodeRequest` has exactly these two consumers. -## Proposal +### Proposal Export one envelope-routing skeleton from `json/rpc`, generic in the result type, and rebuild `dispatch` on top of it: @@ -67,7 +67,7 @@ i665-mcp); if that never happens, the duplication may be cheaper than the three-continuation indirection. Decide when a third consumer appears, or fold into the 66D envelope work if it touches the same lines anyway. -## Tasks +### Tasks - [ ] Evaluate the `routeRequest` shape against the 66D `validated`/`toolMethod` restructuring in `fjs/protocol/mcp/todo/README.md` @@ -76,7 +76,7 @@ fold into the 66D envelope work if it touches the same lines anyway. on it, migrate `mcpStep`. - [ ] Run `npx tsc` and `fjs t`. -## Related +### Related - `errorResponseOf` / `successResponseOf` (`../module.f.mjs`) — the envelope *constructors*, exported from this module; this issue is the envelope diff --git a/fjs/protocol/mcp/todo/output-type.md b/fjs/protocol/mcp/todo/output-type.md index 4e42a13bf..2ae9a4bae 100644 --- a/fjs/protocol/mcp/todo/output-type.md +++ b/fjs/protocol/mcp/todo/output-type.md @@ -1,6 +1,6 @@ -# Operation Output Type +## Operation Output Type -- **Priority:** P3 -- **Status:** open +**Priority:** P3 +**Status:** open An MCP server doesn't require to provide but we should require that. diff --git a/fjs/rtti/todo/checked-const-pin.md b/fjs/rtti/todo/checked-const-pin.md index 32c8b358d..2e4e3e027 100644 --- a/fjs/rtti/todo/checked-const-pin.md +++ b/fjs/rtti/todo/checked-const-pin.md @@ -1,9 +1,9 @@ -# A checked pin for schema `const`s +## A checked pin for schema `const`s **Priority:** P3 **Status:** open — needs a decision, no design agreed -## Problem +### Problem A schema bound to a `const` has to pin its literal, and the only spelling available is a cast: @@ -42,7 +42,7 @@ See "Prefer a `const` type parameter to a cast at the call site" in [`fjs/AGENTS.md`](../../AGENTS.md) for the rule this would extend from arguments to declarations. -## Why it is not obviously right +### Why it is not obviously right - **It invents a runtime function to carry a type-level fact.** The identity call survives into the emitted JavaScript. `fjs/AGENTS.md` says never to @@ -60,7 +60,7 @@ arguments to declarations. schemas with literal members and on exported schemas whose only consumer is generic. -## Tasks +### Tasks - [ ] Decide whether a runtime identity function is acceptable for this, or whether the declaration pin stays a cast. @@ -71,7 +71,7 @@ arguments to declarations. - [ ] Convert in batches, diffing declaration emit per batch, per the method in [`../../../todo/inline-type-casts.md`](../../../todo/inline-type-casts.md). -## Related +### Related - [`../../../todo/inline-type-casts.md`](../../../todo/inline-type-casts.md) — the audit of inline casts, which excluded `@type {const}` wholesale. diff --git a/fjs/rtti/todo/excluded-string-values.md b/fjs/rtti/todo/excluded-string-values.md index d472440b7..d978ef796 100644 --- a/fjs/rtti/todo/excluded-string-values.md +++ b/fjs/rtti/todo/excluded-string-values.md @@ -1,15 +1,15 @@ -# Exclude specific string values from a schema +## Exclude specific string values from a schema **Priority:** P3 **Status:** open -## Problem +### Problem rtti's `Type` ADT has no negation. `Const`, `Tag0`/`Tag1`, and `Or` are all *positive* — they state what a value must match, never what it must not be. There is no way to write "any string except these" as a schema. -## Why it matters for `../../edag` +### Why it matters for `../../edag` `index`'s `string` branch (`../../edag/module.f.mjs`) is meant to admit any property name *except* `'__proto__'` and `'constructor'` — see "the current decision is to @@ -31,12 +31,12 @@ Which of these is worth it depends on whether any other schema in the codebase t out to need the same "all but a few" shape; if `edag` stays the only caller, the layered check is probably simpler than growing the `Type` ADT for one consumer. -## Related +### Related - [`../../edag/module.f.mjs`](../../edag/module.f.mjs) — `index`'s doc comment notes the gap and points here. - [`../../../spec/todo/2330-property-accessor.md`](../../../spec/todo/2330-property-accessor.md) — the prohibited-name list this would enforce. -- [Closed containers](../README.md#closed-containers) — the other extension to the +- [Structs and tuples are closed](../README.md#structs-and-tuples-are-closed) — the other extension to the `Type` ADT (exact/closed containers), for comparison: it shipped because its data-form mapping was worked out end to end first; this one has no mapping yet. diff --git a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md index b6f68391f..e92e2e37f 100644 --- a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md +++ b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md @@ -1,9 +1,9 @@ -# The readers' verdict path dispatches overridable operations after a read +## The readers' verdict path dispatches overridable operations after a read **Priority:** P2 **Status:** open -## Problem +### Problem Reading a member of a hostile value can run **arbitrary code** — an accessor — and everything a reader does after that read trusts whatever the accessor @@ -39,7 +39,7 @@ accessor has already run arbitrary code in the host, so this hardening is about the readers' own answers staying theirs, not about containing the host. -## Tasks +### Tasks - [ ] Extend the discipline the rebuilds and `eachEntry` state to the post-read functions of `common/module.f.mjs`: capture the intrinsics diff --git a/fjs/rtti/todo/identity-aware-parse.md b/fjs/rtti/todo/identity-aware-parse.md index c9c4b983f..1bf192ad7 100644 --- a/fjs/rtti/todo/identity-aware-parse.md +++ b/fjs/rtti/todo/identity-aware-parse.md @@ -1,10 +1,10 @@ -# Identity-aware `parse` and `validate` +## Identity-aware `parse` and `validate` **Priority:** P3 (correctness for `parse`) / P2 (`validate`'s CPU blowup is a DoS vector on a public input boundary, not just a fidelity gap) **Status:** open -## Problem +### Problem Neither `parse` nor `validate` track input identity — no notion of "I already handled this exact reference elsewhere." For `parse`, that loses information silently @@ -29,7 +29,7 @@ input the "public `Function` constructor input" threat model (`todo/edag-stage1-discussion.md`, "Validation") exists to guard against — a caller does not need a *large* value to burn CPU, just a deeply *shared* one. -### `parse`'s identity loss +#### `parse`'s identity loss `parse` always constructs a fresh container per schema position it visits — it has no notion of "I already built this for the same input reference elsewhere, reuse that." @@ -54,7 +54,7 @@ data's meaning — two structurally equal values are just two equal values, and fresh containers is what makes `parse` a safe reader of untrusted, possibly-aliased input in the first place (see `../README.md`, "The two schema-form readers"). -### `validate`'s cycle-unsafety +#### `validate`'s cycle-unsafety A genuinely cyclic value (an array that is its own ancestor) makes `validate(exp)` recurse until `RangeError`, instead of returning a validation error. @@ -69,7 +69,7 @@ arbitrary code execution and a `RangeError` here is not the interesting attack. `edag` ever gains a wire format with back-references, cycles become reachable through that channel too, and this reasoning should be revisited then. -## Why it matters for `../../edag` +### Why it matters for `../../edag` The EDAG is the one schema in this codebase where reference identity between operand positions *is* part of the value's meaning — see @@ -99,7 +99,7 @@ message — the `parse` gap) or runs `validate` against a graph shaped by an unt possibly adversarial caller instead of a proof's small fixtures (the `validate` gap, already live today, cost-wise, for *any* caller of `validate(exp)` on a real graph). -## Possible direction (not decided) +### Possible direction (not decided) Both need memoization keyed off the **input** reference, not the schema position — a `WeakMap`/`WeakSet` populated as the reader walks a container, checked before doing the @@ -123,7 +123,7 @@ work for that input again: Which of these `edag` will actually need — and whether `validate`'s fix lives in the generic engine or as an edag-specific layer on top — isn't decided yet. -## Related +### Related - [`../../edag/module.f.mjs`](../../edag/module.f.mjs) — the schema this matters for; references this TODO. diff --git a/fjs/rtti/todo/prefix-then-rest-tuple.md b/fjs/rtti/todo/prefix-then-rest-tuple.md index f4180e344..7b620a0b7 100644 --- a/fjs/rtti/todo/prefix-then-rest-tuple.md +++ b/fjs/rtti/todo/prefix-then-rest-tuple.md @@ -1,9 +1,9 @@ -# A tuple schema with a fixed prefix and a homogeneous rest, spelled inline +## A tuple schema with a fixed prefix and a homogeneous rest, spelled inline **Priority:** — **Status:** closed — not pursuing -## The gap +### The gap A `Tuple` schema (`readonly Type[]`) pins one schema per position. There is no way to write "this literal tag, then any number of further positions, all matching this one @@ -11,7 +11,7 @@ schema" as a single `Const` tuple — `array`/`record` say exactly that, but onl their *own* single schema position, not spread inline into a bigger tuple's remaining slots. -## Decided: `edag` will not chase the spec's flat spelling +### Decided: `edag` will not chase the spec's flat spelling The EDAG spec's structural-operations table writes array and object constructors flat and variadic: `['[]', ...elements]`, `['{}', ...entries]`. `edag/module.f.mjs` @@ -23,7 +23,7 @@ That nested form is not a workaround standing in for the flat one — it is the representation. Growing the rtti `Type` ADT to match the flat spelling has no other motivating consumer in this codebase, so there is nothing open here to track. -## Related +### Related - [`../../edag/module.f.mjs`](../../edag/module.f.mjs) — `array`/`object` use the nested form. diff --git a/fjs/rtti/todo/proof-shared-asserts.md b/fjs/rtti/todo/proof-shared-asserts.md index eac3a7535..d4ceb2a57 100644 --- a/fjs/rtti/todo/proof-shared-asserts.md +++ b/fjs/rtti/todo/proof-shared-asserts.md @@ -1,15 +1,15 @@ -# Use `result`'s `unwrap` in the parse proof +## Use `result`'s `unwrap` in the parse proof **Priority:** P5 **Status:** open -## Problem +### Problem `fjs/rtti/parse/proof.f.mjs:28` hand-rolls an `unwrap` that duplicates `unwrap` from `fjs/types/result/module.f.mjs:53` — assert `'ok'`, return the payload. -## History +### History This issue used to be about sharing `assertOk` / `assertError` / `assertErrorPath` and roughly 80% of the proof tree between @@ -27,12 +27,12 @@ Do **not** hoist `assertOk` / `assertError` to `fjs/asserts/module.f.mjs` on the strength of the old proposal — with one consumer there is nothing to share, and the repo's rule is to hoist when a second consumer exists. -## Tasks +### Tasks - [ ] Replace the local `unwrap` in `parse/proof.f.mjs` with `unwrap` from `fjs/types/result/module.f.mjs`. - [ ] `npx tsc`, `fjs t`. -## Related +### Related - `fjs/types/result/module.f.mjs` — the `unwrap` to reuse. diff --git a/fjs/rtti/todo/schema-walk-own-indices.md b/fjs/rtti/todo/schema-walk-own-indices.md index 63001cd5e..c1e5342da 100644 --- a/fjs/rtti/todo/schema-walk-own-indices.md +++ b/fjs/rtti/todo/schema-walk-own-indices.md @@ -1,9 +1,9 @@ -# Walk a container schema by own indices, or keep walking it by iteration +## Walk a container schema by own indices, or keep walking it by iteration **Priority:** P4 **Status:** open — a decision about the canonical data form, not a patch to one reader -## Problem +### Problem Every reader of a container schema walks it by **iteration**: [`../common/module.f.mjs`](../common/module.f.mjs)'s `tupleSchemaEntries` with @@ -44,7 +44,7 @@ FunctionalScript can express neither schema — it has no symbols and no mutatio neither can be pinned by a `.f.mjs` proof. That is also the reason the current behaviour is documented rather than guarded. -## Proposal +### Proposal Undecided; the two options are not a ladder. @@ -75,7 +75,7 @@ the second is bounded by its length — while spellings that differ only in how they say "nothing there" answer alike on the same values. Neither decision is a prerequisite for the other. -## Tasks +### Tasks - [ ] Decide between iteration and own indices for a container schema walk. - [ ] If own indices win, change `tupleSchemaEntries` and `containerUnion` @@ -83,7 +83,7 @@ prerequisite for the other. - [ ] Re-word "A hole is a declared position" in `../README.md` to match whichever is chosen. -## Related +### Related - [`../common/module.f.mjs`](../common/module.f.mjs) — `tupleSchemaEntries`, whose doc comment records why iteration is the current choice. diff --git a/fjs/rtti/todo/undeclared-members-declared-scan.md b/fjs/rtti/todo/undeclared-members-declared-scan.md index c78c3b08e..d445ecb7c 100644 --- a/fjs/rtti/todo/undeclared-members-declared-scan.md +++ b/fjs/rtti/todo/undeclared-members-declared-scan.md @@ -1,9 +1,9 @@ -# `undeclaredMembers` scans `declared` linearly per member +## `undeclaredMembers` scans `declared` linearly per member **Priority:** P3 **Status:** open -## Problem +### Problem `undeclaredMembers` in [`../common/module.f.mjs`](../common/module.f.mjs) answers "is `k` declared?" with `declared.some(d => d === k)` — a linear scan @@ -21,7 +21,7 @@ route undeclared members through this one walk (which is the point of the shared rule; see the function's own doc) — but only the *array* kind at scale: a struct's `declared` list is its key list, rarely large. -## Tasks +### Tasks - [ ] Answer membership in O(1): build the membership test once from `declared` (`new Set(declared)` is the §3.1-sanctioned construction) diff --git a/fjs/sul/level/hash/README.md b/fjs/sul/level/hash/README.md index 936403c78..6018639b6 100644 --- a/fjs/sul/level/hash/README.md +++ b/fjs/sul/level/hash/README.md @@ -1,7 +1,7 @@ # SUL Hash-Level Encoding Encodes a stream of level-3 SUL symbols into level-4 hash symbols using a -[Patricia trie](../../../patricia_trie/README.md) and the 256-bit [SUL identifier](../../id/README.md). +[Patricia trie](../../../types/patricia_trie/README.md) and the 256-bit [SUL identifier](../../id/README.md). ## Background diff --git a/fjs/types/btree/todo/find-path-item-typing.md b/fjs/types/btree/todo/find-path-item-typing.md index 27d923a1c..d7c65df8f 100644 --- a/fjs/types/btree/todo/find-path-item-typing.md +++ b/fjs/types/btree/todo/find-path-item-typing.md @@ -1,4 +1,4 @@ -# `btree/find` casts every tuple it builds or indexes +## `btree/find` casts every tuple it builds or indexes **Priority:** P3 **Status:** open diff --git a/nanvm-lib/todo/string-debug-placement.md b/nanvm-lib/todo/string-debug-placement.md index 5f61c3b80..4abbffadd 100644 --- a/nanvm-lib/todo/string-debug-placement.md +++ b/nanvm-lib/todo/string-debug-placement.md @@ -1,9 +1,9 @@ -# Move `Debug for String` to `vm/string/debug.rs` +## Move `Debug for String` to `vm/string/debug.rs` **Priority:** P5 **Status:** open -## Problem +### Problem Bespoke `Debug` impls live in each type's own directory — `impl Debug for BigInt` in `nanvm-lib/src/vm/bigint/debug.rs` and `impl Debug for Function` @@ -30,7 +30,7 @@ follow, and it is the same separation-of-concerns issue that `string-utf16-from-impls.md` records for the sibling UTF-16 `From` impls in `impls/from.rs` — that todo does not cover the Debug impl. -## Proposal +### Proposal Pure move, no abstraction change: create `nanvm-lib/src/vm/string/debug.rs` containing `impl Debug for String`, register `mod debug;` in @@ -39,12 +39,12 @@ containing `impl Debug for String`, register `mod debug;` in types). Coordinate with `string-utf16-from-impls.md` so all string-domain conversion/formatting code lands in `vm/string/` in one pass. -## Tasks +### Tasks - [ ] Move the impl to `vm/string/debug.rs`; register the module. - [ ] `cargo test`, `cargo clippy`, `cargo fmt -- --check`. -## Related +### Related - [string-utf16-from-impls.md](./string-utf16-from-impls.md) — same misplacement for the string `From` impls; do both together. diff --git a/todo/037-language-design-map.md b/todo/037-language-design-map.md index f5bedc5e1..affd94107 100644 --- a/todo/037-language-design-map.md +++ b/todo/037-language-design-map.md @@ -1,4 +1,4 @@ -# 37. Language Design: references in containers. +## 37. Language Design: references in containers. **Priority:** P3 **Status:** open diff --git a/todo/044-error-handling-pattern.md b/todo/044-error-handling-pattern.md index addff58ba..789ccd781 100644 --- a/todo/044-error-handling-pattern.md +++ b/todo/044-error-handling-pattern.md @@ -1,4 +1,4 @@ -# 44. Follow `?` error handling pattern. +## 44. Follow `?` error handling pattern. **Priority:** P3 **Status:** open diff --git a/todo/088-python.md b/todo/088-python.md index 607f11221..a091b6ed5 100644 --- a/todo/088-python.md +++ b/todo/088-python.md @@ -1,4 +1,4 @@ -# Functional Python +## Functional Python **Priority:** P3 **Status:** open @@ -12,12 +12,12 @@ |array |tuple | |object |dict | -## Problems +### Problems 1. JS `string` is an immutable array of UTF16 characters, Python is a UNICODE sequence (most likely UTF-8 internally). 2. `undefined` has no direct mapping in Python. -## Conclusion +### Conclusion The languages are quite different. It would be better to develop Functional/CA Python separately without limitations from JavaScript. After that, we can design how we can interop these VMs. diff --git a/todo/116-tsgo-regression.md b/todo/116-tsgo-regression.md index 307ab53d9..c571eb09f 100644 --- a/todo/116-tsgo-regression.md +++ b/todo/116-tsgo-regression.md @@ -1,4 +1,4 @@ -# 116. Report the TSGO regression. +## 116. Report the TSGO regression. **Priority:** P3 **Status:** open diff --git a/todo/123-tsgo-types-node.md b/todo/123-tsgo-types-node.md index cc4a60708..995256cfa 100644 --- a/todo/123-tsgo-types-node.md +++ b/todo/123-tsgo-types-node.md @@ -1,4 +1,4 @@ -# 123. `tsgo` asks for `"types": ["node"]` in tsconfig. +## 123. `tsgo` asks for `"types": ["node"]` in tsconfig. **Priority:** P3 **Status:** open diff --git a/todo/134-nominal-types-proposal.md b/todo/134-nominal-types-proposal.md index 8731fc30c..70bca1273 100644 --- a/todo/134-nominal-types-proposal.md +++ b/todo/134-nominal-types-proposal.md @@ -1,4 +1,4 @@ -# 134. A proposal for nominal types in TypeScript. +## 134. A proposal for nominal types in TypeScript. **Priority:** P3 **Status:** open diff --git a/todo/144-ts-prototype-functions.md b/todo/144-ts-prototype-functions.md index 096e51b9d..7487600ed 100644 --- a/todo/144-ts-prototype-functions.md +++ b/todo/144-ts-prototype-functions.md @@ -1,4 +1,4 @@ -# 144. TypeScript proposal: distinguish prototype member functions from free functions. +## 144. TypeScript proposal: distinguish prototype member functions from free functions. **Priority:** P3 **Status:** open diff --git a/todo/blocked/automatic-method-binding.md b/todo/blocked/automatic-method-binding.md index 26f533aaf..380fd5361 100644 --- a/todo/blocked/automatic-method-binding.md +++ b/todo/blocked/automatic-method-binding.md @@ -1,4 +1,4 @@ -# Automatic method binding +## Automatic method binding **Priority:** P3 **Status:** blocked diff --git a/todo/blocked/bigint-bit-len.md b/todo/blocked/bigint-bit-len.md index 172d0dd32..9e91b0654 100644 --- a/todo/blocked/bigint-bit-len.md +++ b/todo/blocked/bigint-bit-len.md @@ -1,4 +1,4 @@ -# `BigInt.bitLen()` +## `BigInt.bitLen()` **Priority:** P3 **Status:** blocked diff --git a/todo/blocked/bun-optional-chain-parentheses.md b/todo/blocked/bun-optional-chain-parentheses.md index 2c5f5a861..6258f702a 100644 --- a/todo/blocked/bun-optional-chain-parentheses.md +++ b/todo/blocked/bun-optional-chain-parentheses.md @@ -1,4 +1,4 @@ -# Parentheses do not end an optional chain in bun +## Parentheses do not end an optional chain in bun **Priority:** P3 **Status:** blocked diff --git a/todo/blocked/integer-as-bigint.md b/todo/blocked/integer-as-bigint.md index 5878b6eff..e166e20a0 100644 --- a/todo/blocked/integer-as-bigint.md +++ b/todo/blocked/integer-as-bigint.md @@ -1,4 +1,4 @@ -# Integer literal `123` is a `bigint` +## Integer literal `123` is a `bigint` **Priority:** P3 **Status:** blocked diff --git a/todo/blocked/js-extension-type-annotations.md b/todo/blocked/js-extension-type-annotations.md index 87853f2af..77a83ce4b 100644 --- a/todo/blocked/js-extension-type-annotations.md +++ b/todo/blocked/js-extension-type-annotations.md @@ -1,4 +1,4 @@ -# Switch back to `.js` extension when Type Annotations lands +## Switch back to `.js` extension when Type Annotations lands **Priority:** P3 **Status:** blocked diff --git a/todo/blocked/jsdoc-typedef-doc-declaration-emit.md b/todo/blocked/jsdoc-typedef-doc-declaration-emit.md index c141d98d4..4b7fcd37c 100644 --- a/todo/blocked/jsdoc-typedef-doc-declaration-emit.md +++ b/todo/blocked/jsdoc-typedef-doc-declaration-emit.md @@ -1,9 +1,9 @@ -# JSDoc `@typedef` documentation is dropped by tsgo declaration emit +## JSDoc `@typedef` documentation is dropped by tsgo declaration emit **Priority:** P2 **Status:** blocked -## Trigger +### Trigger The upstream issue below is filed at [microsoft/typescript-go](https://github.com/microsoft/typescript-go/issues), @@ -12,7 +12,7 @@ repository's `devDependencies`. Until then, substantial documented type APIs live in `types.ts` (whose declaration comments emit through the normal TypeScript pipeline), per `todo/migrate-typescript-to-mjs.md`. -## Problem +### Problem Documentation written on a JSDoc `@typedef` in authored `.mjs` can vanish from the emitted `.d.mts`, so the published package loses exactly its type @@ -59,7 +59,7 @@ prose in every measured shape. microsoft/TypeScript#43534, fixed for the services layer only, and microsoft/TypeScript#61664.) -## Reproduction +### Reproduction `repro.mjs`, compiled with `tsc --allowJs --checkJs --declaration --emitDeclarationOnly --strict`: @@ -162,7 +162,7 @@ type, and the doc block lands on `post`. Delete the header block (making the typedef block the first thing in the file) and the same input attaches the doc to `export type T` in full. -## Ready-to-file upstream issue +### Ready-to-file upstream issue Title: **Declaration emit loses JSDoc `@typedef` documentation when the block precedes a declaration or declares multiple typedefs** @@ -195,7 +195,7 @@ Body: > declaration emit untouched), #61664 (proposes stripping redundant JSDoc > type directives while keeping documentation). -## Tasks +### Tasks - [ ] File the issue at `microsoft/typescript-go` (the regression is in tsgo; strada's milder trimming/duplication is already tracked upstream) and @@ -204,7 +204,7 @@ Body: type-level APIs still need the `types.ts` placement solely for documentation fidelity. -## Related +### Related - [`todo/migrate-typescript-to-mjs.md`](../migrate-typescript-to-mjs.md) — "Typedef documentation does not survive declaration emit". diff --git a/todo/blocked/jsdoc-typedef-strip-internal.md b/todo/blocked/jsdoc-typedef-strip-internal.md index fadfd6d1f..5d594d26c 100644 --- a/todo/blocked/jsdoc-typedef-strip-internal.md +++ b/todo/blocked/jsdoc-typedef-strip-internal.md @@ -1,4 +1,4 @@ -# Use `@internal` for private JSDoc typedefs +## Use `@internal` for private JSDoc typedefs **Priority:** P3 **Status:** blocked diff --git a/todo/blocked/lexicographic-integer-keys.md b/todo/blocked/lexicographic-integer-keys.md index 7e7208181..19ebab8b3 100644 --- a/todo/blocked/lexicographic-integer-keys.md +++ b/todo/blocked/lexicographic-integer-keys.md @@ -1,4 +1,4 @@ -# Integer object keys in lexicographic order +## Integer object keys in lexicographic order **Priority:** P3 **Status:** blocked diff --git a/todo/blocked/pipeline-operator.md b/todo/blocked/pipeline-operator.md index 582c2f08f..72f3832a7 100644 --- a/todo/blocked/pipeline-operator.md +++ b/todo/blocked/pipeline-operator.md @@ -1,4 +1,4 @@ -# Pipeline operator +## Pipeline operator **Priority:** P3 **Status:** blocked diff --git a/todo/blocked/undefined-removes-property.md b/todo/blocked/undefined-removes-property.md index 45249ce2e..ef35638b3 100644 --- a/todo/blocked/undefined-removes-property.md +++ b/todo/blocked/undefined-removes-property.md @@ -1,4 +1,4 @@ -# Assigning `undefined` to a property removes it +## Assigning `undefined` to a property removes it **Priority:** P3 **Status:** blocked diff --git a/todo/blocked/utf8-strings.md b/todo/blocked/utf8-strings.md index af25f1314..506fbaae6 100644 --- a/todo/blocked/utf8-strings.md +++ b/todo/blocked/utf8-strings.md @@ -1,4 +1,4 @@ -# UTF-8 strings instead of UTF-16 +## UTF-8 strings instead of UTF-16 **Priority:** P3 **Status:** blocked diff --git a/todo/blocked/wasmtime-threads.md b/todo/blocked/wasmtime-threads.md index 361053e31..7be56b298 100644 --- a/todo/blocked/wasmtime-threads.md +++ b/todo/blocked/wasmtime-threads.md @@ -1,4 +1,4 @@ -# Restore Wasmtime coverage for multi-threaded WASM +## Restore Wasmtime coverage for multi-threaded WASM **Priority:** P3 **Status:** blocked diff --git a/todo/camap.md b/todo/camap.md index 9653530d9..e81dfd880 100644 --- a/todo/camap.md +++ b/todo/camap.md @@ -1,4 +1,4 @@ -# An internal representation of a map CAPL +## An internal representation of a map CAPL **Priority:** P4 **Status:** open diff --git a/todo/changelog-from-git-history.md b/todo/changelog-from-git-history.md index eab1eb397..9de315a28 100644 --- a/todo/changelog-from-git-history.md +++ b/todo/changelog-from-git-history.md @@ -1,9 +1,9 @@ -# Investigate generating the changelog from Git history +## Investigate generating the changelog from Git history **Priority:** P4 **Status:** open -## Problem +### Problem Even as per-PR files ([changelog/README.md](../changelog/README.md)), changelog entries are authored by hand while the same information — commits, @@ -20,7 +20,7 @@ history is immutable, so a badly worded source could never be fixed — while a committed entry can be fixed by a cleanup PR. The `**BREAKING CHANGES:**` marker also drives version bumps, and that signal must stay reviewed. -## Proposal +### Proposal Evaluate at least these designs before removing `changelog/`: @@ -42,7 +42,7 @@ Decide on criteria: determinism of the published pages, where review happens, how a published mistake gets fixed, and where the breaking-change signal for versioning comes from. -## Related +### Related - [commit-message-standard.md](./commit-message-standard.md) — the commit message format that must be in force before the history this would read diff --git a/todo/changelog-website.md b/todo/changelog-website.md index ddcbc85e3..0d54bc4eb 100644 --- a/todo/changelog-website.md +++ b/todo/changelog-website.md @@ -1,15 +1,15 @@ -# Publish the changelog on the website +## Publish the changelog on the website **Priority:** P4 **Status:** open -## Problem +### Problem The release history lives only in the repository. Users of the package should be able to read it on the FunctionalScript website as an index of releases and a page per release. -## Proposal +### Proposal Extend the website generator (`fjs/website`) to read the `changelog/` directory and emit an index page plus one page per release. The repository @@ -30,7 +30,7 @@ small self-hosted parser for the entry subset (paragraphs, list items, inline code, bold, links — all the current entries use), or reconsider the entry format. The BNF machinery is a natural fit for the parser. -## Tasks +### Tasks - [ ] Parser for the changelog Markdown subset - [ ] Read both release forms: `.md` files and `/` @@ -38,7 +38,7 @@ format. The BNF machinery is a natural fit for the parser. - [ ] Release index page and per-release pages in `fjs/website` - [ ] Link the changelog from the landing page -## Related +### Related - [changelog/README.md](../changelog/README.md) — defines the structure and the Markdown subset this consumes diff --git a/todo/commit-message-enforcement.md b/todo/commit-message-enforcement.md index 916178745..43f1feddf 100644 --- a/todo/commit-message-enforcement.md +++ b/todo/commit-message-enforcement.md @@ -1,4 +1,4 @@ -# Enforce the commit-message standard before merge +## Enforce the commit-message standard before merge **Priority:** P3 **Status:** open — the format is adopted, in @@ -8,7 +8,7 @@ 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 documentation edit rather than a linter change plus a rule migration. -## Problem +### Problem Once the standard is documented, it is still only a convention: nothing stops a PR with a malformed title, a malformed `Changelog:` section, or a behavior @@ -16,7 +16,7 @@ change carrying no changelog note at all from merging. The format must be machine-checked before the merge button enables, or the history the changelog generator would read degrades one forgotten PR at a time. -## Proposal +### Proposal The format is enforced *before* merge by a **required status check**: a workflow on `pull_request` with types `[opened, edited, synchronize, reopened]` @@ -63,7 +63,7 @@ message); a post-merge audit job on `push` to `main` that compares the landed message against the PR and fails loudly; commit-metadata rulesets would block it outright but require an Enterprise plan. -## Tasks +### Tasks - [ ] PR-lint workflow (title format, `Changelog:` section well-formed and consistent with `changelog/unreleased/.md` when either is present) @@ -79,7 +79,7 @@ would block it outright but require an Enterprise plan. - [ ] Post-merge audit: on `push` to `main`, verify the landed commit message matches the PR title `(#NNN)` and description -## Related +### Related - [CONTRIBUTING.md](../CONTRIBUTING.md#commit-messages) — the format this enforces - [commit-message-standard.md](./commit-message-standard.md) — the reasoning diff --git a/todo/commit-message-standard.md b/todo/commit-message-standard.md index a00c406f1..c2ea70bd9 100644 --- a/todo/commit-message-standard.md +++ b/todo/commit-message-standard.md @@ -1,4 +1,4 @@ -# Standard for commit messages merged into `main` +## Standard for commit messages merged into `main` **Priority:** P2 **Status:** wip — the format is adopted, in @@ -8,7 +8,7 @@ section, squash-only. Release tagging was rejected, see below. behind it, and only the repository settings remain undone — they need a maintainer with admin rights and cannot land in a PR. -## Problem +### Problem [changelog-from-git-history.md](./changelog-from-git-history.md) investigates deriving the changelog from Git history. Whatever that investigation decides, @@ -24,9 +24,9 @@ concatenation of the branch's intermediate commit messages: unreviewed noise Releases are not tagged (`git tag` is empty), so release boundaries exist only as version-bump commit titles. -## Proposal +### Proposal -### One squash commit per PR — no other merge method +#### One squash commit per PR — no other merge method - **Squash and merge only.** Disable "Create a merge commit" and "Rebase and merge" in the repository settings. A rebase merge replays the branch's @@ -50,7 +50,7 @@ as version-bump commit titles. one PR, in merge order — the "correct order, nothing missed" property comes from this rule alone. -### Title: the PR title, in the changelog-entry style +#### Title: the PR title, in the changelog-entry style The squash title is the PR title, so this is a PR-title standard, checkable before merge: @@ -67,7 +67,7 @@ before merge: practice — 38 of the last 200 titles exceed it today. - A release PR's title is the bare version: `0.45.0`. -### Body: the PR description, carrying the changelog entry +#### Body: the PR description, carrying the changelog entry Set the repository's default squash message to **"Pull request title and description"**, so the body is reviewed prose instead of the intermediate @@ -107,7 +107,7 @@ Changelog: section remains; if it loses, the section cost was a few reviewed lines per PR. -### Tag releases — rejected +#### Tag releases — rejected This section proposed tagging each release commit `vX.Y.Z`, so that "entries in this release" is a range between two tags rather than a parse of @@ -122,7 +122,7 @@ the bare version, or from the changelog directories themselves. Recorded in [changelog/README.md](../changelog/README.md#breaking-changes-and-versioning) so the question is not reopened by the next reader. -## Tasks +### Tasks - [ ] Repository settings: squash-only, default squash message "Pull request title and description", branch protection (PRs required, linear @@ -136,7 +136,7 @@ Machine-checking the format before merge is a separate, later step: [commit-message-enforcement.md](./commit-message-enforcement.md), unblocked by the AGENTS.md adoption above. -## Related +### Related - [commit-message-enforcement.md](./commit-message-enforcement.md) — the pre-merge check that turns this convention into a rule; starts after the diff --git a/todo/edag-stage1-discussion.md b/todo/edag-stage1-discussion.md index 752aa1976..785cf0d28 100644 --- a/todo/edag-stage1-discussion.md +++ b/todo/edag-stage1-discussion.md @@ -1,4 +1,4 @@ -# EDAG stage 1: discussion +## EDAG stage 1: discussion **Priority:** P2 **Status:** open — working document for designing the stage 1 function @@ -18,7 +18,7 @@ and [function-frame](../spec/todo/3111-function-frame.md); VM-internal call lowering belongs to [call-like-instructions](../spec/todo/9100-call-like-instructions.md). -## Baseline: an expression DAG with anchored evaluation +### Baseline: an expression DAG with anchored evaluation *This baseline supersedes the original index-based sequence proposal; the revision history is recorded in subjects 1 and 8.* @@ -105,7 +105,7 @@ export default [",", ] ``` -### The core invariant +#### The core invariant **Any validated EDAG behaves on the VM exactly as the corresponding source behaves on a JavaScript engine.** @@ -191,14 +191,14 @@ Agreed points (not under discussion): common case but would need a spread marker for those. Same for every other argument operand: `"?.()"`'s and the call steps' (subject 6). -## Operations +### Operations The operations we want, with their stage. Every operand is an operation node; `node` below means any of them. The stage numbers match the concrete DJS rollout in [`compile-modules-to-edag.md`](../fjs/djs/todo/compile-modules-to-edag.md). -### Structural operations +#### Structural operations **"Stage" names which compiler/interpreter task is scoped to emit or consume an operation — not when the EDAG schema itself admits it.** The schema @@ -319,7 +319,7 @@ it denotes. Symbol tags never collide with word tags, so both live in one namespace. -### Operators +#### Operators **Negation is a word tag, `"neg"`, not `"-"`'s unary arity.** An earlier draft of this document overloaded `"-"` by arity instead — `["-", a]` @@ -344,7 +344,7 @@ its JS spelling. All operators are post-stage-1: stage 1 has no operators at all. -### Other operations +#### Other operations |Form|JS|Stage|Notes| |----|--|-----|-----| @@ -501,14 +501,14 @@ Consequences: freely duplicable**: an object or array constructor creates observable identity even though it cannot throw (subject 1). -## Assumptions +### Assumptions Different graph-building rules follow from which of these assumptions are accepted or rejected. Enumerated first, analyzed separately; each ends as **accepted** or **rejected**, and the graph-building rules in the subjects are then derived from the accepted set. -### A1. No side effects +#### A1. No side effects **Status:** accepted @@ -517,7 +517,7 @@ with the same parameters always produces the same result. This is FS principle 1 ([spec/README.md](../spec/README.md)); with A2, "same result" applies to runs that complete. -### A2. The runner may interrupt +#### A2. The runner may interrupt **Status:** accepted @@ -547,7 +547,7 @@ Consequently FS code cannot rely on interruption or on its absence, and an interrupt is observably the same opaque failure as any other (A4 contract). -### A3. Throws are preserved +#### A3. Throws are preserved **Status:** accepted @@ -564,7 +564,7 @@ always completes with a value, an uninterrupted FS run completes with that value — so for spec-deterministic behavior, FS fails iff JS throws or the runner interrupts (A2). -### A4. Computation order is preserved +#### A4. Computation order is preserved **Status:** rejected — replaced by the opaque-error contract @@ -642,9 +642,9 @@ Still illegal with A4 rejected: - **merging** identical constructor nodes — object identity is observable and sharing stays semantic (subject 1). -## Subjects +### Subjects -### 1. Structure: indices vs. nesting vs. references +#### 1. Structure: indices vs. nesting vs. references **Status:** decided (revised) @@ -700,7 +700,7 @@ Indices reappear only as **derived artifacts**: canonical serialization (subject 9) and bytecode both derive them from the graph; they are never authored and never part of the EDAG. -### 2. Arguments reference +#### 2. Arguments reference **Status:** decided @@ -728,7 +728,7 @@ const f = (...a) => a[5] // [".", ["args"], 5, null] const g = (a) => a[5] // [".", [".", ["args"], 0, null], 5, null] ``` -### 3. Lazy operators and the branch extension path +#### 3. Lazy operators and the branch extension path **Status:** decided (for what stage 1 must guarantee) @@ -759,7 +759,7 @@ open: shared across a function boundary, and "whose arguments?" never arises. -### 4. Object constructor: ordered entries +#### 4. Object constructor: ordered entries **Status:** decided (revised) @@ -866,7 +866,7 @@ instead and lose the property. This is the rule the DJS parser and serializer already follow — [spec: the `__proto__` key](../spec/README.md#the-__proto__-key). -### 5. Validation +#### 5. Validation **Status:** open (list agreed in direction, details when the RTTI schema is written) @@ -920,7 +920,7 @@ the FJS compiler would never emit. To validate: rules above instead. The initial Stage 2 validator/proofs for this boundary are tracked by [`compile-modules-to-edag.md`](../fjs/djs/todo/compile-modules-to-edag.md). -### 6. Command vocabulary vs. the existing spec names +#### 6. Command vocabulary vs. the existing spec names **Status:** decided @@ -1005,7 +1005,7 @@ deliberately left unused by EDAG. Word tags now survive only where JS genuinely has no expression spelling: `"args"`, `"frame"`, `"self"`, `"throw"`, `"own"`. -### 7. Top-level shape of a function +#### 7. Top-level shape of a function **Status:** open @@ -1020,7 +1020,7 @@ body node or a wrapper carrying metadata — parameter count for erases names and arity; without a wrapper, `toString` can only print a rest-parameter spelling). -### 8. `","`: anchored evaluation +#### 8. `","`: anchored evaluation **Status:** decided (revised: the merge is the `","` operation) @@ -1133,7 +1133,7 @@ without `","`; these rules bind the operation when it is introduced. carries its guards as a `","` node inside the arm — per-branch effect membership with no extra machinery. -### 9. Canonical graph serialization and hashing +#### 9. Canonical graph serialization and hashing **Status:** parked — deliberately deferred; not part of the stage 1 discussion. The notes below are kept so nothing is rediscovered later. @@ -1162,7 +1162,7 @@ the **graph**, not a tree expansion: is a cycle *between* functions, which `["self"]` does not reach — either the partner is passed as an argument, or the group is hashed together with members addressed by index. -### 10. Free variables: module consts, imports, built-ins +#### 10. Free variables: module consts, imports, built-ins **Status:** open @@ -1273,7 +1273,7 @@ Related: `["throw", …]` exists as an operation partly because it needs none of this ([Operations](#operations)). -### 11. `let`, loops, and tail calls +#### 11. `let`, loops, and tail calls **Status:** open @@ -1317,7 +1317,7 @@ Related: [mutability](../spec/todo/mutability.md) treats `let` as stage zero of ownership tracking; whatever shape is chosen here must not require the EDAG to model mutable *objects*, only threaded state. -### 12. `toString(f)`: real, runnable source +#### 12. `toString(f)`: real, runnable source **Status:** open (requirement agreed; details to settle) diff --git a/todo/eslint.md b/todo/eslint.md index 1b02c3513..8487231b6 100644 --- a/todo/eslint.md +++ b/todo/eslint.md @@ -1,4 +1,4 @@ -# ESLint for rules `tsc` cannot express +## ESLint for rules `tsc` cannot express **Priority:** P2 **Status:** open diff --git a/todo/flow.md b/todo/flow.md index 7fb71d349..13f55a472 100644 --- a/todo/flow.md +++ b/todo/flow.md @@ -1,9 +1,9 @@ -# Flow: dataflow graphs with deferred input binding +## Flow: dataflow graphs with deferred input binding **Priority:** P3 **Status:** open -## Problem +### Problem We want to describe computations on sequences as an immutable graph whose external inputs and outputs are bound *later*, by an engine: @@ -28,13 +28,13 @@ trees, fs2 pipes, Clojure transducers. The collection-kind side (ordered sequence vs unordered bag vs set, and which operations each kind admits) is the Boom hierarchy; see the *Future work* section. -## Proposal +### Proposal Start minimal: one input kind — an ordered sequence bound to `fjs/types/list` — and a naive in-process engine. No RTTI yet: input types are checked by TypeScript. -### Module +#### Module `fjs/flow/module.f.mjs` defines `Flow`: a node of the graph, describing a sequence of `O` computed from an environment of type `E`. A `Flow` is @@ -52,7 +52,7 @@ const { result } = transduce(sum)(lengths) run({ text: ['hello', 'world'] })(result) // [10] ``` -### The universal operator: `Transducer` +#### The universal operator: `Transducer` One operator shape covers every stage; an engine interprets nothing else: @@ -136,7 +136,7 @@ Clojure's 0-arity `init` cannot do this); `Step` and its variants match Haskell's `machines` (`Yield`/`Stop`); `next` is Rx's `onNext`; `A` is Akka's materialized value. -### Graph operations +#### Graph operations Two primitives: @@ -169,7 +169,7 @@ library-level derivations: - stateful decoders/parsers (`utf8Decode`, tokenizers): buffer in `S`, flush in `end`, report `A = Result` -### Failure convention +#### Failure convention There is no error channel, so a failing stage *ends its output sequence early* — and downstream cannot distinguish "input ended" from "input @@ -184,7 +184,7 @@ must surface failure in one of two ways: the stages that matter — making "who checks what" visible in the graph instead of implicit in the engine. -### Engines +#### Engines `run` in the same module is the first, naive engine: it binds the graph directly to `fjs/types/list` and recomputes shared nodes. Flow variant @@ -208,7 +208,7 @@ Planned engine work, each a separate change: - Longer term: incremental, streaming (chunked), and distributed engines; output binding (multiple named outputs per graph). -### Future work: RTTI and collection kinds +#### Future work: RTTI and collection kinds - Replace the TypeScript-only environment with RTTI-described named inputs (`fjs/rtti`), so a graph can be validated, serialized, and shipped @@ -220,7 +220,7 @@ Planned engine work, each a separate change: commutative operation, etc. (Boom hierarchy). Engines may exploit declared laws (tree reduction, out-of-order merge, retry safety). -## Tasks +### Tasks - [ ] `fjs/flow/module.f.mjs` — `Flow`, `Transducer`/`Step`/`Terminal`, primitives `input` and `transduce`, derived operations, naive `run` @@ -234,7 +234,7 @@ Planned engine work, each a separate change: - [ ] RTTI-typed named inputs - [ ] unordered collection kinds with law-constrained operations -## Related +### Related - [fjs/types/list/module.f.mjs](../fjs/types/list/module.f.mjs) — the sequence type the naive engine binds to diff --git a/todo/inline-type-casts.md b/todo/inline-type-casts.md index 0b6757732..fd2934aff 100644 --- a/todo/inline-type-casts.md +++ b/todo/inline-type-casts.md @@ -1,4 +1,4 @@ -# Audit: inline `/** @type {T} */ (v)` casts +## Audit: inline `/** @type {T} */ (v)` casts **Priority:** P2 **Status:** implemented — 273 of 357 removed or converted; 84 remain, each with a reason below diff --git a/todo/new-pl.md b/todo/new-pl.md index aacc34c30..71dd4a38a 100644 --- a/todo/new-pl.md +++ b/todo/new-pl.md @@ -1,15 +1,15 @@ -# New PL +## New PL **Priority:** P3 **Status:** open -## Problem +### Problem If we can start from scratch how would it look like. -## Proposal +### Proposal -### JSON compatibility +#### JSON compatibility JSON and its derivatives such as YAML are the most popular data formats. It has the most essential types that are used in modern information technologies: @@ -32,7 +32,7 @@ And this is understandable because JSON (JavaScript Object Notation) is derived Even if we don't always like the syntax, the semantics of these basic types make a lot of practical sense in modern computer science and software engineering, having the most popular basic types and allowing grouping by order (arrays) and mapping (objects). -### Data JavaScript +#### Data JavaScript FunctionalScript already defines a subset of JavaScript, has no side effects, and supports all JSON types. We also would like to make it serializable, which makes it a kind of ideal PL for handling data and communications, including as data for AI agents and models. Example: @@ -65,7 +65,7 @@ You just need to add `export default` at the beginning of your JSON. Moving further, we will use FunctionalScript as a foundation to build our programming language. A key design constraint is that most existing FunctionalScript modules should be reusable in the new language with little or no modification. This gives us a large library from day one and means the new PL can be validated incrementally against real code. -## Multiple Syntaxes +### Multiple Syntaxes Because the canonical identity of a program is the content hash of its semantic representation (EDAG/IR), not its source text, syntax becomes a rendering preference. Multiple surface syntaxes can compile to the same semantic node and therefore share the same hash — they are literally the same program. @@ -83,7 +83,7 @@ add = lambda a, b: a + b Both would produce identical content hashes. Tooling can display any module in whichever syntax the developer prefers, and cross-syntax references just work — a module written in Python syntax can import a function written in JS syntax with no friction, since the identity layer is below syntax. -### Content-Addressability +#### Content-Addressability FunctionalScript programs can be run as content-addressable, but their behavior could be different compared to running the same program on a JavaScript engine, for example: @@ -115,7 +115,7 @@ Function identity is a harder problem. Two functions are semantically equal if t The catch is that normalization is not fixed forever — a smarter normalizer in a future VM version may canonicalize more aggressively, causing functions that were distinct under the old normalizer to become equal. This means function hashes are implicitly versioned by the normalizer that produced them. We likely need to encode the normalizer version in the hash (or the VM version), so that old and new hashes remain meaningful and comparable across VM generations. -### Numbers +#### Numbers Currently, the literal `2` has type `number` which is, usually, a 64-bit floating-point number (IEEE 754 double). Initially, JavaScript didn't have biginteger, but currently they are in the ECMAScript standard. Because ECMAScript can't break backward compatibility, they introduced another syntax to describe bigint literals: `2n`, but JSON doesn't support this syntax. While we can have JSON parsers and writers that read and write bigints, the syntax is not the same anymore. The deeper problem is not the `n` suffix itself, but that built-in operations which logically require integers — such as array indexing (`array[i]`) — accept `number` (float) instead of `bigint`. This creates an impedance mismatch: code must either use `number` throughout (losing precision for large integers) or use `bigint` and constantly convert at API boundaries. In a PL designed from scratch, `bigint` is the default integer type and such APIs accept it natively, so no suffix or conversion is needed: @@ -149,13 +149,13 @@ This `2`/`2.0` split doesn't have to wait for a new PL. [fjs/djs/todo/json-bigin [todo/blocked/integer-as-bigint.md](./blocked/integer-as-bigint.md) tracks the ECMAScript-level version of this same idea (`123` becoming the language's own default integer type) — blocked because ECMAScript is unlikely to ever make `bigint` the primary numeric type for compatibility reasons. This section is the escape hatch: a new PL isn't bound by that compatibility constraint, so it doesn't have to wait. -### UTF8 String +#### UTF8 String Current implementation of a `string` in JavaScript is UTF-16. While we can have a proposal that ECMAScript supports a new type `utf8`, something like `u'Hello, world!'`, the default JS string will always be UTF-16. In a new PL, we don't want to have UTF-16 at all, only UTF-8. See [todo/blocked/utf8-strings.md](./blocked/utf8-strings.md) — blocked on ECMAScript ever adopting a native UTF-8 string primitive, which a new PL doesn't need to wait for. -### Separation Between Arrays and Objects +#### Separation Between Arrays and Objects An array type shouldn't be derived from an object type. It should be a separate type. @@ -163,7 +163,7 @@ An array type shouldn't be derived from an object type. It should be a separate assert(typeof([]) === 'array') // I wish ``` -### Always Lexicographical Order +#### Always Lexicographical Order Properties inside objects should be sorted in lexicographical order. Currently, JS objects preserve insertion order: @@ -182,7 +182,7 @@ Note: JS already sorts integer-like keys numerically before string keys, so the See [todo/blocked/lexicographic-integer-keys.md](./blocked/lexicographic-integer-keys.md) — blocked on ECMAScript, which is unlikely to ever drop the numeric-key special-casing for compatibility reasons; a new PL adopts pure lexicographic order directly instead. -### Assigning +#### Assigning Assigning `undefined` to a property should remove the property. @@ -194,7 +194,7 @@ This way we can also keep better compatibility with JSON. See [todo/blocked/undefined-removes-property.md](./blocked/undefined-removes-property.md) — blocked on ECMAScript for compatibility reasons; a new PL isn't bound by that and adopts the behavior directly. -### Pipeline Operator +#### Pipeline Operator ```js a |> b @@ -202,7 +202,7 @@ a |> b See [todo/blocked/pipeline-operator.md](./blocked/pipeline-operator.md) — blocked on the TC39 pipeline operator proposal reaching Stage 4; a new PL can adopt the syntax without waiting on that. -### Automatic Binding +#### Automatic Binding ```ts const m = [42].at @@ -213,19 +213,19 @@ This would break JavaScript compatibility. See [todo/blocked/automatic-method-binding.md](./blocked/automatic-method-binding.md) — blocked because ECMAScript is unlikely to ever fix this for compatibility reasons; a new PL, not being bound by that compatibility constraint, can define `this`-free method extraction directly. -### `BigInt.bitLen` +#### `BigInt.bitLen` ECMAScript proposal for `BigInt.bitLen()` See [todo/blocked/bigint-bit-len.md](./blocked/bigint-bit-len.md) — blocked on the same proposal reaching Stage 4 and shipping in Node.js LTS. -### Type Annotations +#### Type Annotations Switch back to `.js` extension if [Type Annotations](https://github.com/tc39/proposal-type-annotations) lands in ECMAScript. See [todo/blocked/js-extension-type-annotations.md](./blocked/js-extension-type-annotations.md) — the FunctionalScript-specific tracking issue for this same trigger. -### Type System +#### Type System The new PL starts with type stripping: type annotations are syntax only and are erased before execution, with no built-in type checker. This keeps the core runtime simple and avoids baking in a specific type system. @@ -244,7 +244,7 @@ This has several advantages: - Different modules in the same program can use different type systems - New type systems can be published as ordinary packages without changes to the core language -### Serializable EDAG +#### Serializable EDAG JavaScript's `Function.prototype.toString()` exposes source text, but it is unreliable: all major engines produce incorrect output for closures that capture variables from an outer scope, because the returned string omits the surrounding context needed to reconstruct the function's meaning. @@ -263,11 +263,11 @@ Because the EDAG is a plain data value (most likely JSON), it can be stored, tra This also enables runtime metaprogramming and macro-like code generation without resorting to `eval` or string manipulation. -### Module Identity +#### Module Identity Because content-addressability is a core goal, module identity should be hash-based rather than path-based. A module is identified by the hash of its content, not its file path. Paths become human-friendly aliases that resolve to a hash at publish time. This enables reliable deduplication, caching, and dependency pinning without a lockfile. -### Last Expression is Return and Export (Compatible with JSON) +#### Last Expression is Return and Export (Compatible with JSON) Currently, this JavaScript code doesn't export the object the way JSON would: @@ -289,7 +289,7 @@ const a = f() To make FunctionalScript more compatible with the new PL, it should prohibit non `return` statements at the end of a function, or no `export` at the end of the module. -### Pattern Matching +#### Pattern Matching We adopt the syntax from the [TC39 pattern matching proposal](https://github.com/tc39/proposal-pattern-matching) (`match`/`when`), which avoids conflicting with the existing `switch` statement and covers conditional expressions cleanly: @@ -303,7 +303,7 @@ const area = match (shape) { This keeps compatibility with valid JavaScript syntax and aligns with a likely future ECMAScript direction. Exhaustiveness can be checked statically when combined with type annotations (see Type Annotations section). This also composes naturally with the last-expression-as-return proposal. -### Effect Syntax Sugar +#### Effect Syntax Sugar Algebraic effects generalize `async`/`await`, exceptions, and other control-flow abstractions into a single declarative mechanism. The proposed syntax mirrors `async`/`await` but is not tied to a specific effect type: @@ -316,7 +316,7 @@ const a = effect() => { `effect` marks a function that may perform effects; `perform` suspends the computation and delegates to the nearest handler, similar to how `await` delegates to the runtime scheduler. See [Effects](../fjs/effects/) -### Result Syntax Sugar +#### Result Syntax Sugar In modern software engineering, `throw` is increasingly treated as a way to signal an *unexpected, fatal* condition — a bug, a broken invariant, a crash. It unwinds the stack, is invisible in a function's signature, and is easy to forget to handle. That model is a poor fit for *expected* failures. IO errors (a missing file, a refused connection, a malformed response) are a normal part of a program's behavior and must be handled deliberately, not caught as exceptions somewhere up the stack. Encoding these errors as values — handling errors instead of throwing exceptions — makes them explicit, type-checkable, and impossible to ignore by accident. @@ -367,7 +367,7 @@ Notes and open questions: - A combinator/method form (e.g. `result.map(...)`, `result.andThen(...)`, or a `|>` pipeline of them) covers the cases where short-circuit propagation is not what you want — transforming or recovering from the error inline. - `throw` remains in the language for genuinely unexpected/fatal conditions (broken invariants, unreachable branches), keeping a clear split: `Result` for expected errors, `throw` for bugs. -## Tasks +### Tasks - [ ] Decide on integer literal syntax (`2` = bigint, `2.0` = float); accept `2n` as a redundant-but-valid alternate spelling for JS/djs source compatibility - [ ] Specify the `TypeError` thrown on mixed `bigint`/`number` arithmetic (matching current JS behavior) and the explicit conversion functions required at the boundary diff --git a/todo/rtti-type-system.md b/todo/rtti-type-system.md index 06146a7f4..1fe7ac3ca 100644 --- a/todo/rtti-type-system.md +++ b/todo/rtti-type-system.md @@ -1,4 +1,4 @@ -# RTTI as the type system +## RTTI as the type system **Priority:** P3 **Status:** open diff --git a/todo/strict-static-analysis.md b/todo/strict-static-analysis.md index 282b0604c..532f66f03 100644 --- a/todo/strict-static-analysis.md +++ b/todo/strict-static-analysis.md @@ -1,4 +1,4 @@ -# Check the JavaScript side as strictly as the Rust side +## Check the JavaScript side as strictly as the Rust side **Priority:** P2 **Status:** open diff --git a/todo/tsconfig-strict-flags.md b/todo/tsconfig-strict-flags.md index 6f417af55..9c28eaeee 100644 --- a/todo/tsconfig-strict-flags.md +++ b/todo/tsconfig-strict-flags.md @@ -1,4 +1,4 @@ -# Additional strictness flags for `tsconfig.json` +## Additional strictness flags for `tsconfig.json` **Priority:** P2 **Status:** wip diff --git a/todo/types-for-fs.md b/todo/types-for-fs.md index 792ce03dc..1fd5a0e64 100644 --- a/todo/types-for-fs.md +++ b/todo/types-for-fs.md @@ -1,4 +1,4 @@ -# Types for FunctionalScript +## Types for FunctionalScript **Priority:** P5 **Status:** open @@ -20,7 +20,7 @@ const a: A5 = { p: 5 } f(a) // a.p === 42 // :-( ``` -## How is should work +### How is should work ```ts type A = { p: number } @@ -85,7 +85,7 @@ f(ap) // compilation error. We may have a special version of TypeScript and it should have a run-time description, similar to [RTTI](../fjs/rtti/README.md) -## Benefits +### Benefits - **Use `.js` files.** Type annotations are written in comments, so the code stays plain JavaScript and runs as-is, with no additional compilation step. This could be very attractive for projects which don't accept TypeScript because of the additional compilation step. The syntax could be similar to JSDoc: From 03a96e8c51ad0a1cb0f8ccd386bc03ba0f031c5e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:44:35 +0000 Subject: [PATCH 168/370] fjs: drop `@module` from every file that is not a package entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fjs/AGENTS.md §2 states the 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". 102 files carried it anyway: 89 `types.ts`, 11 proof files, plus `fjs/bnf/testlib.f.mjs` and `fjs/emergent_testing/browser.mjs`. Every one of the 137 real entry points already had it, so the rule was being inverted almost everywhere it applied and enforced nowhere. `@module` is what a documentation generator uses to decide that a file *is* a module and to attach the block to it, so leaving it on type-only stubs and proofs would list them beside the modules they describe once `fjs/website/todo/publish-deno-doc-to-website.md` lands. That issue is the reason to fix the files rather than relax the rule. The tag appeared in three block shapes and the surrounding blank comment line is removed with it, so each block keeps the spacing AGENTS.md shows: a trailing `@module` takes the separator above it, a middle one takes one of its two separators, and a leading one (a block that was only `@module` plus `@import` tags) takes the separator below. No runtime or type-level change: `npx tsc` is clean and the suite passes 3513/3513. Nothing enforces this rule mechanically, which is how it drifted this far; a check belongs in the `fjs ci` workflow, filed separately. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- fjs/asn.1/types.ts | 2 -- fjs/asserts/types.ts | 2 -- fjs/basen/types.ts | 2 -- fjs/bnf/data/proof.f.mjs | 2 -- fjs/bnf/data/types.ts | 2 -- fjs/bnf/descent/proof.f.mjs | 2 -- fjs/bnf/descent/types.ts | 2 -- fjs/bnf/ll1/types.ts | 2 -- fjs/bnf/matcher/types.ts | 2 -- fjs/bnf/proof.f.mjs | 2 -- fjs/bnf/testlib.f.mjs | 2 -- fjs/bnf/token_symbol/types.ts | 2 -- fjs/bnf/types.ts | 2 -- fjs/cas/evo/types.ts | 2 -- fjs/cas/types.ts | 2 -- fjs/ci/common/types.ts | 2 -- fjs/ci/nix/proof.f.mjs | 2 -- fjs/ci/nix/types.ts | 2 -- fjs/ci/types.ts | 2 -- fjs/cli/types.ts | 2 -- fjs/common/monoid/types.ts | 2 -- fjs/crypto/pow/types.ts | 2 -- fjs/crypto/secp/types.ts | 2 -- fjs/crypto/sha2/types.ts | 2 -- fjs/crypto/sign/types.ts | 2 -- fjs/crypto/vdf/types.ts | 2 -- fjs/dev/types.ts | 2 -- fjs/djs/ast/types.ts | 2 -- fjs/djs/parser/types.ts | 2 -- fjs/djs/tokenizer/types.ts | 2 -- fjs/djs/transpiler/types.ts | 2 -- fjs/djs/types.ts | 2 -- fjs/effects/list/types.ts | 2 -- fjs/effects/memory/types.ts | 2 -- fjs/effects/mock/types.ts | 2 -- fjs/effects/node/memory/proof.mjs | 2 -- fjs/effects/node/types.ts | 2 -- fjs/effects/node/virtual/types.ts | 2 -- fjs/effects/types.ts | 2 -- fjs/emergent_testing/browser.mjs | 2 -- fjs/emergent_testing/types.ts | 2 -- fjs/js/tokenizer/types.ts | 2 -- fjs/media/html/types.ts | 2 -- fjs/media/json/extended/types.ts | 2 -- fjs/media/json/number/types.ts | 2 -- fjs/media/json/parser/types.ts | 2 -- fjs/media/json/rtti/proof.f.mjs | 2 -- fjs/media/json/tokenizer/types.ts | 2 -- fjs/media/json/types.ts | 2 -- fjs/media/nix/types.ts | 2 -- fjs/media/revision/types.ts | 2 -- fjs/media/rust/proof.f.mjs | 2 -- fjs/media/type/types.ts | 2 -- fjs/media/types.ts | 2 -- fjs/nanvm/proof.f.mjs | 2 -- fjs/nanvm/rust/proof.f.mjs | 2 -- fjs/nanvm/types.ts | 2 -- fjs/nanvm/update/proof.f.mjs | 2 -- fjs/protocol/json_rpc/types.ts | 2 -- fjs/protocol/mcp/stdio/types.ts | 2 -- fjs/protocol/mcp/types.ts | 2 -- fjs/rtti/common/types.ts | 2 -- fjs/rtti/data/types.ts | 2 -- fjs/rtti/host.proof.mjs | 2 -- fjs/rtti/parse/types.ts | 2 -- fjs/rtti/ts/types.ts | 2 -- fjs/rtti/types.ts | 2 -- fjs/sul/id/types.ts | 2 -- fjs/sul/level/hash/types.ts | 2 -- fjs/sul/level/literal/types.ts | 2 -- fjs/sul/types.ts | 2 -- fjs/text/sgr/types.ts | 2 -- fjs/text/types.ts | 2 -- fjs/text/utf16/types.ts | 2 -- fjs/text/utf8/types.ts | 2 -- fjs/types/array/types.ts | 2 -- fjs/types/bigfloat/types.ts | 2 -- fjs/types/bigint/types.ts | 2 -- fjs/types/bit_vec/types.ts | 2 -- fjs/types/btree/find/types.ts | 2 -- fjs/types/btree/types/types.ts | 2 -- fjs/types/byte_set/types.ts | 2 -- fjs/types/function/compare/types.ts | 2 -- fjs/types/function/operator/types.ts | 2 -- fjs/types/function/types.ts | 2 -- fjs/types/list/types.ts | 2 -- fjs/types/nibble_set/types.ts | 2 -- fjs/types/nominal/types.ts | 2 -- fjs/types/nullable/types.ts | 2 -- fjs/types/object/types.ts | 2 -- fjs/types/option/types.ts | 2 -- fjs/types/ordered_map/types.ts | 2 -- fjs/types/patricia_trie/types.ts | 2 -- fjs/types/phantom/types.ts | 2 -- fjs/types/prime_field/types.ts | 2 -- fjs/types/range/types.ts | 2 -- fjs/types/range_map/types.ts | 2 -- fjs/types/result/types.ts | 2 -- fjs/types/sorted_list/types.ts | 2 -- fjs/types/sorted_set/types.ts | 2 -- fjs/types/string_set/types.ts | 2 -- fjs/types/ts/types.ts | 2 -- 102 files changed, 204 deletions(-) diff --git a/fjs/asn.1/types.ts b/fjs/asn.1/types.ts index 98bed3594..53daddb47 100644 --- a/fjs/asn.1/types.ts +++ b/fjs/asn.1/types.ts @@ -1,7 +1,5 @@ /** * Types for ASN.1 BER/DER encoding and decoding over bit vectors. - * - * @module */ import type { Vec } from '../types/bit_vec/types.ts' diff --git a/fjs/asserts/types.ts b/fjs/asserts/types.ts index 6cfbcb354..bfbf7ff2a 100644 --- a/fjs/asserts/types.ts +++ b/fjs/asserts/types.ts @@ -1,7 +1,5 @@ /** * Type-level assertion helpers. - * - * @module */ /** diff --git a/fjs/basen/types.ts b/fjs/basen/types.ts index a7dc081f4..e89ba87b7 100644 --- a/fjs/basen/types.ts +++ b/fjs/basen/types.ts @@ -1,7 +1,5 @@ /** * Types for the shared bit-codec factory. - * - * @module */ import type { Vec } from '../types/bit_vec/types.ts' diff --git a/fjs/bnf/data/proof.f.mjs b/fjs/bnf/data/proof.f.mjs index eae6616c6..33fe377b4 100644 --- a/fjs/bnf/data/proof.f.mjs +++ b/fjs/bnf/data/proof.f.mjs @@ -1,6 +1,4 @@ /** - * @module - * * @import { RuleSet } from './types.ts' */ diff --git a/fjs/bnf/data/types.ts b/fjs/bnf/data/types.ts index 5f1104640..392e75da0 100644 --- a/fjs/bnf/data/types.ts +++ b/fjs/bnf/data/types.ts @@ -1,7 +1,5 @@ /** * Types for the serializable BNF intermediate representation (IR). - * - * @module */ import type { StringMap } from '../../types/object/types.ts' diff --git a/fjs/bnf/descent/proof.f.mjs b/fjs/bnf/descent/proof.f.mjs index 292df2986..839d89e20 100644 --- a/fjs/bnf/descent/proof.f.mjs +++ b/fjs/bnf/descent/proof.f.mjs @@ -1,6 +1,4 @@ /** - * @module - * * @import { CodePoint } from '../../text/utf16/types.ts' * @import { RuleSet } from '../data/types.ts' * @import { DescentMatch, CodePointMeta, DescentMatchResult } from './types.ts' diff --git a/fjs/bnf/descent/types.ts b/fjs/bnf/descent/types.ts index 282d91453..753f8a5fa 100644 --- a/fjs/bnf/descent/types.ts +++ b/fjs/bnf/descent/types.ts @@ -5,8 +5,6 @@ * `Ast>` from [`../matcher`](../matcher). What is declared * here is what belongs to *this* backend — the metadata-carrying leaf, the * diagnostics a backtracking matcher can report, and its public result. - * - * @module */ import type { CodePoint } from '../../text/utf16/types.ts' diff --git a/fjs/bnf/ll1/types.ts b/fjs/bnf/ll1/types.ts index d04b047a8..6834b7cce 100644 --- a/fjs/bnf/ll1/types.ts +++ b/fjs/bnf/ll1/types.ts @@ -1,7 +1,5 @@ /** * Types for the LL(1) dispatch/matcher backend. - * - * @module */ import type { CodePoint } from '../../text/utf16/types.ts' diff --git a/fjs/bnf/matcher/types.ts b/fjs/bnf/matcher/types.ts index 671931edd..309e072ed 100644 --- a/fjs/bnf/matcher/types.ts +++ b/fjs/bnf/matcher/types.ts @@ -1,8 +1,6 @@ /** * Type-level API for the layer every BNF matcher backend shares: the position * it matches at, the AST it builds, and the result that pairs them. - * - * @module */ /** diff --git a/fjs/bnf/proof.f.mjs b/fjs/bnf/proof.f.mjs index ec2279664..1a4c6374a 100644 --- a/fjs/bnf/proof.f.mjs +++ b/fjs/bnf/proof.f.mjs @@ -1,6 +1,4 @@ /** - * @module - * * @import { Rule } from './types.ts' */ diff --git a/fjs/bnf/testlib.f.mjs b/fjs/bnf/testlib.f.mjs index 7bb384d60..ba0589caf 100644 --- a/fjs/bnf/testlib.f.mjs +++ b/fjs/bnf/testlib.f.mjs @@ -1,6 +1,4 @@ /** - * @module - * * @import { Ast, AstTag } from './matcher/types.ts' * @import { Rule } from './types.ts' */ diff --git a/fjs/bnf/token_symbol/types.ts b/fjs/bnf/token_symbol/types.ts index 6f57b1046..8e5226538 100644 --- a/fjs/bnf/token_symbol/types.ts +++ b/fjs/bnf/token_symbol/types.ts @@ -1,7 +1,5 @@ /** * Types for encoding multi-character token names as single BNF input symbols. - * - * @module */ import type { Nullable } from '../../types/nullable/types.ts' diff --git a/fjs/bnf/types.ts b/fjs/bnf/types.ts index c219dbd3b..f8d10b9f5 100644 --- a/fjs/bnf/types.ts +++ b/fjs/bnf/types.ts @@ -1,7 +1,5 @@ /** * Type-level API for BNF grammar primitives and helpers. - * - * @module */ import type { StringMap } from '../types/object/types.ts' diff --git a/fjs/cas/evo/types.ts b/fjs/cas/evo/types.ts index 45d2c4f46..4432b922d 100644 --- a/fjs/cas/evo/types.ts +++ b/fjs/cas/evo/types.ts @@ -1,8 +1,6 @@ /** * Type-level API for `fjs/cas/evo/module.f.mjs`: the Evo cache shape and the * `Evo` API surface it builds. - * - * @module */ import type { Effect, NotImplemented, Operation } from '../../effects/types.ts' diff --git a/fjs/cas/types.ts b/fjs/cas/types.ts index 9c0c86e50..4b9532443 100644 --- a/fjs/cas/types.ts +++ b/fjs/cas/types.ts @@ -1,7 +1,5 @@ /** * Types for content-addressable storage utilities. - * - * @module */ import type { Vec } from '../types/bit_vec/types.ts' diff --git a/fjs/ci/common/types.ts b/fjs/ci/common/types.ts index 70c307258..c560de766 100644 --- a/fjs/ci/common/types.ts +++ b/fjs/ci/common/types.ts @@ -1,8 +1,6 @@ /** * Type-level API for shared CI types: GitHub Actions step/job RTTI schemas, * the `MetaStep` representation used by tool-specific modules. - * - * @module */ import type { Ts } from '../../rtti/ts/types.ts' diff --git a/fjs/ci/nix/proof.f.mjs b/fjs/ci/nix/proof.f.mjs index 0975e733a..7a8b798ec 100644 --- a/fjs/ci/nix/proof.f.mjs +++ b/fjs/ci/nix/proof.f.mjs @@ -1,8 +1,6 @@ /** * Proofs for generated CI flakes. * - * @module - * * @import { NixJob } from './types.ts' */ diff --git a/fjs/ci/nix/types.ts b/fjs/ci/nix/types.ts index 7b36f2cd1..b8d665fc0 100644 --- a/fjs/ci/nix/types.ts +++ b/fjs/ci/nix/types.ts @@ -1,7 +1,5 @@ /** * Types for generated CI Nix flakes. - * - * @module */ /** A CI job's development environment, one generated flake each. */ diff --git a/fjs/ci/types.ts b/fjs/ci/types.ts index 26840f278..1ec1ff39b 100644 --- a/fjs/ci/types.ts +++ b/fjs/ci/types.ts @@ -1,7 +1,5 @@ /** * Types for the CI workflow generator. - * - * @module */ import type { MetaStep, Os } from './common/types.ts' diff --git a/fjs/cli/types.ts b/fjs/cli/types.ts index c980d9e3d..60a181853 100644 --- a/fjs/cli/types.ts +++ b/fjs/cli/types.ts @@ -1,7 +1,5 @@ /** * Types for the CLI command dispatch table. - * - * @module */ import type { NodeOp, Program } from '../effects/node/types.ts' diff --git a/fjs/common/monoid/types.ts b/fjs/common/monoid/types.ts index ebb449151..e08cedf66 100644 --- a/fjs/common/monoid/types.ts +++ b/fjs/common/monoid/types.ts @@ -1,7 +1,5 @@ /** * The `Monoid` algebraic structure. - * - * @module */ import type { Reduce } from '../../types/function/operator/types.ts' diff --git a/fjs/crypto/pow/types.ts b/fjs/crypto/pow/types.ts index 7de8b37f0..a7f9aa4ae 100644 --- a/fjs/crypto/pow/types.ts +++ b/fjs/crypto/pow/types.ts @@ -1,7 +1,5 @@ /** * Types for Bitcoin-style proof-of-work verification. - * - * @module */ import type { Vec } from '../../types/bit_vec/types.ts' diff --git a/fjs/crypto/secp/types.ts b/fjs/crypto/secp/types.ts index 33e5aaff4..e501c0988 100644 --- a/fjs/crypto/secp/types.ts +++ b/fjs/crypto/secp/types.ts @@ -1,7 +1,5 @@ /** * Types for short Weierstrass elliptic-curve arithmetic over a prime field. - * - * @module */ import type { Fold, Reduce } from '../../types/function/operator/types.ts' diff --git a/fjs/crypto/sha2/types.ts b/fjs/crypto/sha2/types.ts index c5986537c..48c04dfd6 100644 --- a/fjs/crypto/sha2/types.ts +++ b/fjs/crypto/sha2/types.ts @@ -1,7 +1,5 @@ /** * Types for the SHA-2 family of hash functions. - * - * @module */ import type { Tuple } from '../../types/array/types.ts' diff --git a/fjs/crypto/sign/types.ts b/fjs/crypto/sign/types.ts index 9825e3666..e4d08c904 100644 --- a/fjs/crypto/sign/types.ts +++ b/fjs/crypto/sign/types.ts @@ -1,7 +1,5 @@ /** * Type-level API for signing helpers built on secp256k1 and SHA-256 primitives. - * - * @module */ import type { Vec } from '../../types/bit_vec/types.ts' diff --git a/fjs/crypto/vdf/types.ts b/fjs/crypto/vdf/types.ts index 9ccbbd294..e737239c0 100644 --- a/fjs/crypto/vdf/types.ts +++ b/fjs/crypto/vdf/types.ts @@ -1,7 +1,5 @@ /** * Types for the Sloth verifiable delay function. - * - * @module */ import type { Nullable } from '../../types/nullable/types.ts' diff --git a/fjs/dev/types.ts b/fjs/dev/types.ts index 106350f1d..faf54fe94 100644 --- a/fjs/dev/types.ts +++ b/fjs/dev/types.ts @@ -1,7 +1,5 @@ /** * Types for indexing modules and loading FunctionalScript files. - * - * @module */ import type { StringMap } from '../types/object/types.ts' diff --git a/fjs/djs/ast/types.ts b/fjs/djs/ast/types.ts index 8225d6fda..bc95df71a 100644 --- a/fjs/djs/ast/types.ts +++ b/fjs/djs/ast/types.ts @@ -2,8 +2,6 @@ * Type-level API for `fjs/djs/ast/module.f.mjs`: the AST shape `run` * evaluates — `AstModule`, `AstConst`, `AstModuleRef`, `AstArray`, * `AstObject`, and `AstBody`. - * - * @module */ import type { Primitive } from '../types.ts' diff --git a/fjs/djs/parser/types.ts b/fjs/djs/parser/types.ts index 51fdf4146..822c57105 100644 --- a/fjs/djs/parser/types.ts +++ b/fjs/djs/parser/types.ts @@ -2,8 +2,6 @@ * Type-level API for `fjs/djs/parser/module.f.mjs`: the `ParseError` shape * `parseFromTokens` reports, the `_ValueToken` subset `tokenToValue` accepts, * and the parser layer's token alphabet. - * - * @module */ import type { TokenMetadata } from '../../js/tokenizer/types.ts' diff --git a/fjs/djs/tokenizer/types.ts b/fjs/djs/tokenizer/types.ts index 74e3b7783..c27f9580f 100644 --- a/fjs/djs/tokenizer/types.ts +++ b/fjs/djs/tokenizer/types.ts @@ -1,8 +1,6 @@ /** * Type-level API for `fjs/djs/tokenizer/module.f.mjs`: the DJS token shapes * `tokenize`/`tokenizeJs`/`tokenizeString` produce. - * - * @module */ import type { diff --git a/fjs/djs/transpiler/types.ts b/fjs/djs/transpiler/types.ts index d6b77c546..90c8da5b3 100644 --- a/fjs/djs/transpiler/types.ts +++ b/fjs/djs/transpiler/types.ts @@ -1,7 +1,5 @@ /** * Types for the DJS transpiler. - * - * @module */ import type { Unknown } from '../types.ts' diff --git a/fjs/djs/types.ts b/fjs/djs/types.ts index 1ba892335..287a4e80d 100644 --- a/fjs/djs/types.ts +++ b/fjs/djs/types.ts @@ -1,8 +1,6 @@ /** * DJS's own value model: `Primitive`, `Unknown`, `Object`, and `Array`, * layered on top of JSON's `Primitive` with `bigint` and `undefined` added. - * - * @module */ import type { diff --git a/fjs/effects/list/types.ts b/fjs/effects/list/types.ts index d0bf671ee..9ac174033 100644 --- a/fjs/effects/list/types.ts +++ b/fjs/effects/list/types.ts @@ -1,7 +1,5 @@ /** * Types for the effectful cons-list. - * - * @module */ import type { Operation } from '../types.ts' diff --git a/fjs/effects/memory/types.ts b/fjs/effects/memory/types.ts index 844dbb80c..5723dfc1b 100644 --- a/fjs/effects/memory/types.ts +++ b/fjs/effects/memory/types.ts @@ -1,7 +1,5 @@ /** * Types for typed key-value memory effects. - * - * @module */ import type { Phantom } from '../../types/phantom/types.ts' diff --git a/fjs/effects/mock/types.ts b/fjs/effects/mock/types.ts index 500b8d3a7..72977b076 100644 --- a/fjs/effects/mock/types.ts +++ b/fjs/effects/mock/types.ts @@ -1,7 +1,5 @@ /** * Types for mock effect runtimes. - * - * @module */ import type { Result } from "../../types/result/types.ts" diff --git a/fjs/effects/node/memory/proof.mjs b/fjs/effects/node/memory/proof.mjs index 6453027d6..1e4ef86fa 100644 --- a/fjs/effects/node/memory/proof.mjs +++ b/fjs/effects/node/memory/proof.mjs @@ -1,8 +1,6 @@ /** * Node.js interpreter proofs for memory effects. * - * @module - * * @import { Key } from '../../memory/types.ts' * @import { Uuid } from './module.mjs' */ diff --git a/fjs/effects/node/types.ts b/fjs/effects/node/types.ts index 946b2d393..61d848fa3 100644 --- a/fjs/effects/node/types.ts +++ b/fjs/effects/node/types.ts @@ -1,7 +1,5 @@ /** * Types for Node.js effect operations. - * - * @module */ import type { List as EffectList } from '../../types/list/types.ts' diff --git a/fjs/effects/node/virtual/types.ts b/fjs/effects/node/virtual/types.ts index c690cdf5f..cd010d2dd 100644 --- a/fjs/effects/node/virtual/types.ts +++ b/fjs/effects/node/virtual/types.ts @@ -1,8 +1,6 @@ /** * Types for the virtual Node-effect operations used by filesystem and * process tests. - * - * @module */ import type { Vec } from '../../../types/bit_vec/types.ts' diff --git a/fjs/effects/types.ts b/fjs/effects/types.ts index 4164e2db9..eaac59509 100644 --- a/fjs/effects/types.ts +++ b/fjs/effects/types.ts @@ -1,7 +1,5 @@ /** * Types for the core effect system. - * - * @module */ import type { Ok, Error, Result } from '../types/result/types.ts' diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 6a7e82f6d..eb2322d84 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -13,8 +13,6 @@ * iframe therefore renders into that frame, and a proof can drive the module * with a stand-in root. * - * @module - * * @import { TestResult, _TestAndPath } from './types.ts' * @import { Result } from '../types/result/types.ts' */ diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index 20ac2f6c9..0b471434c 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -1,7 +1,5 @@ /** * Types for running and reporting FunctionalScript tests. - * - * @module */ import type { Effect, Operation } from '../effects/types.ts' diff --git a/fjs/js/tokenizer/types.ts b/fjs/js/tokenizer/types.ts index 6f06df20e..2c05a14aa 100644 --- a/fjs/js/tokenizer/types.ts +++ b/fjs/js/tokenizer/types.ts @@ -1,7 +1,5 @@ /** * Types for the JavaScript tokenizer. - * - * @module */ import type { RangeMapArray } from '../../types/range_map/types.ts' diff --git a/fjs/media/html/types.ts b/fjs/media/html/types.ts index dc39fc837..71610fff6 100644 --- a/fjs/media/html/types.ts +++ b/fjs/media/html/types.ts @@ -1,7 +1,5 @@ /** * Type-level API for HTML serialization. - * - * @module */ import type { StringMap } from '../../types/object/types.ts' diff --git a/fjs/media/json/extended/types.ts b/fjs/media/json/extended/types.ts index 6aeb45028..186d90b45 100644 --- a/fjs/media/json/extended/types.ts +++ b/fjs/media/json/extended/types.ts @@ -5,8 +5,6 @@ * This is a runtime representation, not a new syntax: an extended value's * serialized form is ordinary valid JSON text, with no `123n` literal, tagged * object, or quoted-integer convention. - * - * @module */ import type { Primitive as JsonPrimitive, Tree, TreeObject, TreeArray, TreeMapEntries } from '../types.ts' diff --git a/fjs/media/json/number/types.ts b/fjs/media/json/number/types.ts index 986930e5d..fcf556e47 100644 --- a/fjs/media/json/number/types.ts +++ b/fjs/media/json/number/types.ts @@ -1,7 +1,5 @@ /** * Types for the lexical view of a JSON number token. - * - * @module */ /** diff --git a/fjs/media/json/parser/types.ts b/fjs/media/json/parser/types.ts index 00d2560b3..993a0e533 100644 --- a/fjs/media/json/parser/types.ts +++ b/fjs/media/json/parser/types.ts @@ -1,8 +1,6 @@ /** * Types for the shared structural JSON parser: its numeric policy, the tree * that policy produces, and the parser's internal state. - * - * @module */ import type { Tree } from '../types.ts' diff --git a/fjs/media/json/rtti/proof.f.mjs b/fjs/media/json/rtti/proof.f.mjs index 7bc917a6b..48e44caad 100644 --- a/fjs/media/json/rtti/proof.f.mjs +++ b/fjs/media/json/rtti/proof.f.mjs @@ -1,8 +1,6 @@ /** * Proof for the JSON rtti schemas. * - * @module - * * @import { ValidateE } from '../../../rtti/common/types.ts' * @import { Unknown } from '../../../rtti/ts/types.ts' */ diff --git a/fjs/media/json/tokenizer/types.ts b/fjs/media/json/tokenizer/types.ts index 48bb9f187..cf5463f18 100644 --- a/fjs/media/json/tokenizer/types.ts +++ b/fjs/media/json/tokenizer/types.ts @@ -1,7 +1,5 @@ /** * Types for the JSON tokenizer. - * - * @module */ import type { StringToken, NumberToken, ErrorToken, EofToken, JsTokenWithMetadata } from '../../../js/tokenizer/types.ts' diff --git a/fjs/media/json/types.ts b/fjs/media/json/types.ts index 2ea9c9b3f..116bdc91b 100644 --- a/fjs/media/json/types.ts +++ b/fjs/media/json/types.ts @@ -8,8 +8,6 @@ * `Assert>`. The pin is what keeps the two * descriptions of the same data model from drifting apart — and it holds the * `Tree` spelling to the same data model the schemas describe. - * - * @module */ import type { Entry as ObjectEntry } from '../../types/object/types.ts' diff --git a/fjs/media/nix/types.ts b/fjs/media/nix/types.ts index b0c37d762..3f2041035 100644 --- a/fjs/media/nix/types.ts +++ b/fjs/media/nix/types.ts @@ -1,7 +1,5 @@ /** * Type-level API for the Nix expression eDSL. - * - * @module */ type _Identifier = string diff --git a/fjs/media/revision/types.ts b/fjs/media/revision/types.ts index f7371652b..734c932ec 100644 --- a/fjs/media/revision/types.ts +++ b/fjs/media/revision/types.ts @@ -10,8 +10,6 @@ * the schema side of the same recursion: `lock` cannot infer its own type * (a `const` may not reference itself in its own initializer), so it carries * this named annotation instead. - * - * @module */ import type { Assert } from '../../asserts/types.ts' diff --git a/fjs/media/rust/proof.f.mjs b/fjs/media/rust/proof.f.mjs index 32b6cfe16..739ba0be2 100644 --- a/fjs/media/rust/proof.f.mjs +++ b/fjs/media/rust/proof.f.mjs @@ -1,7 +1,5 @@ /** * Proofs for Rust source literals. - * - * @module */ import { assertEq } from '../../asserts/module.f.mjs' diff --git a/fjs/media/type/types.ts b/fjs/media/type/types.ts index 26d8923ee..ce763d2f7 100644 --- a/fjs/media/type/types.ts +++ b/fjs/media/type/types.ts @@ -1,7 +1,5 @@ /** * Types for magic-byte MIME type detection. - * - * @module */ import type { Nullable } from '../../types/nullable/types.ts' diff --git a/fjs/media/types.ts b/fjs/media/types.ts index 337e5114e..4a066f01a 100644 --- a/fjs/media/types.ts +++ b/fjs/media/types.ts @@ -1,8 +1,6 @@ /** * Type-level API for `fjs/media/module.f.mjs`: the `DialectEntry` registry * shape `detect` and `dialectEntry` share with every registered dialect. - * - * @module */ import type { Unknown } from '../rtti/ts/types.ts' diff --git a/fjs/nanvm/proof.f.mjs b/fjs/nanvm/proof.f.mjs index 9c794ea5c..e4e105d6e 100644 --- a/fjs/nanvm/proof.f.mjs +++ b/fjs/nanvm/proof.f.mjs @@ -7,8 +7,6 @@ * it. This module contains no test cases of its own beyond the `jsOnly` * section at the end — adding a case means editing the data. * - * @module - * * @import { Case, EqCase, Op, Struct, Value } from './types.ts' */ diff --git a/fjs/nanvm/rust/proof.f.mjs b/fjs/nanvm/rust/proof.f.mjs index a56d9588d..783617bb5 100644 --- a/fjs/nanvm/rust/proof.f.mjs +++ b/fjs/nanvm/rust/proof.f.mjs @@ -5,8 +5,6 @@ * pins the exact text of every construct the printer can emit, so a change in * layout is a visible diff here and not only in the generated file. * - * @module - * * @import { Data } from '../types.ts' */ diff --git a/fjs/nanvm/types.ts b/fjs/nanvm/types.ts index 0f4f963a2..921243f6b 100644 --- a/fjs/nanvm/types.ts +++ b/fjs/nanvm/types.ts @@ -5,8 +5,6 @@ * behaviour: [`proof.f.mjs`](./proof.f.mjs) runs it against a standard * JavaScript engine, and [`rust/module.f.mjs`](./rust/module.f.mjs) prints it * as the Rust tests in [`test/generated.rs`](./test/generated.rs). - * - * @module */ /** diff --git a/fjs/nanvm/update/proof.f.mjs b/fjs/nanvm/update/proof.f.mjs index daef47b22..1eb2fead6 100644 --- a/fjs/nanvm/update/proof.f.mjs +++ b/fjs/nanvm/update/proof.f.mjs @@ -1,7 +1,5 @@ /** * Proofs for the generated-Rust writer. - * - * @module */ import { exitCode } from '../../effects/node/module.f.mjs' diff --git a/fjs/protocol/json_rpc/types.ts b/fjs/protocol/json_rpc/types.ts index a9b825b8d..f4e064db6 100644 --- a/fjs/protocol/json_rpc/types.ts +++ b/fjs/protocol/json_rpc/types.ts @@ -2,8 +2,6 @@ * Type-level API for `fjs/protocol/json_rpc/module.f.mjs`: `Id`, `Request`, * `RpcError`, and `Response`, derived from the module's own rtti schemas, * plus the `Handler` / `Handlers` shapes a dispatcher is built from. - * - * @module */ import type { Unknown } from '../../media/json/types.ts' diff --git a/fjs/protocol/mcp/stdio/types.ts b/fjs/protocol/mcp/stdio/types.ts index ed613f1c2..110d785d8 100644 --- a/fjs/protocol/mcp/stdio/types.ts +++ b/fjs/protocol/mcp/stdio/types.ts @@ -1,7 +1,5 @@ /** * Types for the stdio transport of JSON-RPC / MCP servers. - * - * @module */ import type { Unknown } from '../../../media/json/types.ts' diff --git a/fjs/protocol/mcp/types.ts b/fjs/protocol/mcp/types.ts index b0bdea80d..71866f5ca 100644 --- a/fjs/protocol/mcp/types.ts +++ b/fjs/protocol/mcp/types.ts @@ -2,8 +2,6 @@ * Type-level API for `fjs/protocol/mcp/module.f.mjs`: the MCP message * schemas' derived types, plus the `McpHandlers`/`ToolEntry`/`Handle`/ * session-state shapes `mcpStep` is built from. - * - * @module */ import type { Ts } from '../../rtti/ts/types.ts' diff --git a/fjs/rtti/common/types.ts b/fjs/rtti/common/types.ts index 7c1079e5e..db29ddcce 100644 --- a/fjs/rtti/common/types.ts +++ b/fjs/rtti/common/types.ts @@ -1,7 +1,5 @@ /** * Type-level API shared by RTTI consumers (`validate`, `parse`). - * - * @module */ import type { Primitive, Unknown } from '../ts/types.ts' diff --git a/fjs/rtti/data/types.ts b/fjs/rtti/data/types.ts index 70239abcd..bcee0fca5 100644 --- a/fjs/rtti/data/types.ts +++ b/fjs/rtti/data/types.ts @@ -6,8 +6,6 @@ * `undefined`, `false`, `true`), numbers, strings, bigints, arrays and * objects — so that union, equality and subset reduce to kind-wise set * operations. See `./README.md` for the design rationale. - * - * @module */ import type { StringMap } from '../../types/object/types.ts' diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index 0277b0b2b..1a3fec618 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -15,8 +15,6 @@ * are cases where reading a member by *entry* and reading it by *index* * disagree — which is what these readers had wrong. * - * @module - * * @import { Type } from './types.ts' * @import { ValidateE } from './common/types.ts' * @import { StringMap } from '../types/object/types.ts' diff --git a/fjs/rtti/parse/types.ts b/fjs/rtti/parse/types.ts index 0e9892271..995f63e86 100644 --- a/fjs/rtti/parse/types.ts +++ b/fjs/rtti/parse/types.ts @@ -1,7 +1,5 @@ /** * Type-level API for RTTI deserialization. - * - * @module */ import type { Type } from '../types.ts' diff --git a/fjs/rtti/ts/types.ts b/fjs/rtti/ts/types.ts index ce99fc017..07a8aa4c1 100644 --- a/fjs/rtti/ts/types.ts +++ b/fjs/rtti/ts/types.ts @@ -6,8 +6,6 @@ * * The runtime `toTs` function (`printer` in `./module.f.mjs`) mirrors `Ts` at value * level, returning a TypeScript type expression string for a given RTTI schema. - * - * @module */ import type { And, Equal } from '../../types/ts/types.ts' diff --git a/fjs/rtti/types.ts b/fjs/rtti/types.ts index e250f2185..3588ed166 100644 --- a/fjs/rtti/types.ts +++ b/fjs/rtti/types.ts @@ -44,8 +44,6 @@ * ## Converting to TypeScript types * * See `./ts/module.f.ts` for `Ts` and the `*Ts` transformer types. - * - * @module */ import type { Assert } from '../asserts/types.ts' diff --git a/fjs/sul/id/types.ts b/fjs/sul/id/types.ts index 8dc4f68f6..e622b70dc 100644 --- a/fjs/sul/id/types.ts +++ b/fjs/sul/id/types.ts @@ -1,7 +1,5 @@ /** * Type-level API for SUL identifiers. - * - * @module */ import type { Nominal } from '../../types/nominal/types.ts' diff --git a/fjs/sul/level/hash/types.ts b/fjs/sul/level/hash/types.ts index a780d86ac..07576fc66 100644 --- a/fjs/sul/level/hash/types.ts +++ b/fjs/sul/level/hash/types.ts @@ -1,7 +1,5 @@ /** * Type-level API for hash-level SUL encoding. - * - * @module */ import type { State } from '../../../types/patricia_trie/types.ts' diff --git a/fjs/sul/level/literal/types.ts b/fjs/sul/level/literal/types.ts index f011fb448..600920283 100644 --- a/fjs/sul/level/literal/types.ts +++ b/fjs/sul/level/literal/types.ts @@ -1,7 +1,5 @@ /** * Type-level API for the literal SUL level encoding. - * - * @module */ import type { Vec } from '../../../types/bit_vec/types.ts' diff --git a/fjs/sul/types.ts b/fjs/sul/types.ts index 23f709822..0a151089d 100644 --- a/fjs/sul/types.ts +++ b/fjs/sul/types.ts @@ -1,7 +1,5 @@ /** * Type-level API for the full SUL streaming encoder. - * - * @module */ import type { InternalState } from '../types/patricia_trie/types.ts' diff --git a/fjs/text/sgr/types.ts b/fjs/text/sgr/types.ts index 464666916..cde570e93 100644 --- a/fjs/text/sgr/types.ts +++ b/fjs/text/sgr/types.ts @@ -1,7 +1,5 @@ /** * Types for ANSI CSI/SGR terminal output helpers. - * - * @module */ export type Stdout = { diff --git a/fjs/text/types.ts b/fjs/text/types.ts index 43e126b26..1ec1b48f6 100644 --- a/fjs/text/types.ts +++ b/fjs/text/types.ts @@ -1,7 +1,5 @@ /** * Types for indented text blocks and UTF-8 bit vectors. - * - * @module */ import type { Vec } from '../types/bit_vec/types.ts' diff --git a/fjs/text/utf16/types.ts b/fjs/text/utf16/types.ts index 5ca45fdbd..e5e53d745 100644 --- a/fjs/text/utf16/types.ts +++ b/fjs/text/utf16/types.ts @@ -1,7 +1,5 @@ /** * Types for UTF-16 code units and Unicode code points. - * - * @module */ /** diff --git a/fjs/text/utf8/types.ts b/fjs/text/utf8/types.ts index 5767645b3..f7db66b5a 100644 --- a/fjs/text/utf8/types.ts +++ b/fjs/text/utf8/types.ts @@ -1,7 +1,5 @@ /** * Types for UTF-8 byte-level encoding and decoding. - * - * @module */ import type { Tuple } from '../../types/array/types.ts' diff --git a/fjs/types/array/types.ts b/fjs/types/array/types.ts index 368cd9b14..e704c019e 100644 --- a/fjs/types/array/types.ts +++ b/fjs/types/array/types.ts @@ -1,7 +1,5 @@ /** * Types for JavaScript immutable arrays. - * - * @module */ import type { Assert } from '../../asserts/types.ts' diff --git a/fjs/types/bigfloat/types.ts b/fjs/types/bigfloat/types.ts index 5c2bd3794..2f4538ba6 100644 --- a/fjs/types/bigfloat/types.ts +++ b/fjs/types/bigfloat/types.ts @@ -1,7 +1,5 @@ /** * Types for big-floats built from bigint mantissa and exponent parts. - * - * @module */ export type BigFloat = readonly [bigint, number] diff --git a/fjs/types/bigint/types.ts b/fjs/types/bigint/types.ts index 359da1f23..85a44e014 100644 --- a/fjs/types/bigint/types.ts +++ b/fjs/types/bigint/types.ts @@ -1,7 +1,5 @@ /** * Operator types specialized to `bigint`. - * - * @module */ import type { diff --git a/fjs/types/bit_vec/types.ts b/fjs/types/bit_vec/types.ts index 93b1d7bed..06961096d 100644 --- a/fjs/types/bit_vec/types.ts +++ b/fjs/types/bit_vec/types.ts @@ -1,7 +1,5 @@ /** * Types for bit vectors normalized on the most-significant bit. - * - * @module */ import type { Sign } from '../function/compare/types.ts' diff --git a/fjs/types/btree/find/types.ts b/fjs/types/btree/find/types.ts index e688e5cd1..436aebb6d 100644 --- a/fjs/types/btree/find/types.ts +++ b/fjs/types/btree/find/types.ts @@ -1,7 +1,5 @@ /** * Types for B-tree lookup results and paths. - * - * @module */ import type { Index } from '../../array/types.ts' diff --git a/fjs/types/btree/types/types.ts b/fjs/types/btree/types/types.ts index d16c93e71..0b8f3adeb 100644 --- a/fjs/types/btree/types/types.ts +++ b/fjs/types/btree/types/types.ts @@ -1,7 +1,5 @@ /** * Shared type definitions for persistent B-tree modules. - * - * @module */ import type { Tuple } from '../../array/types.ts' diff --git a/fjs/types/byte_set/types.ts b/fjs/types/byte_set/types.ts index de4ce510e..5b6fa8ed0 100644 --- a/fjs/types/byte_set/types.ts +++ b/fjs/types/byte_set/types.ts @@ -1,7 +1,5 @@ /** * Type-level API for the byte-set module. - * - * @module */ export type ByteSet = bigint diff --git a/fjs/types/function/compare/types.ts b/fjs/types/function/compare/types.ts index dec10fca7..90568d2fd 100644 --- a/fjs/types/function/compare/types.ts +++ b/fjs/types/function/compare/types.ts @@ -1,7 +1,5 @@ /** * Comparison function types. - * - * @module */ export type Sign = -1 | 0 | 1 diff --git a/fjs/types/function/operator/types.ts b/fjs/types/function/operator/types.ts index 74bd26616..c2a02a7f6 100644 --- a/fjs/types/function/operator/types.ts +++ b/fjs/types/function/operator/types.ts @@ -1,7 +1,5 @@ /** * Common higher-order operator type aliases. - * - * @module */ export type Binary = (a: A) => (b: B) => R diff --git a/fjs/types/function/types.ts b/fjs/types/function/types.ts index 652102337..b445235ed 100644 --- a/fjs/types/function/types.ts +++ b/fjs/types/function/types.ts @@ -1,7 +1,5 @@ /** * Types for function composition. - * - * @module */ /** diff --git a/fjs/types/list/types.ts b/fjs/types/list/types.ts index a564eca58..843b29524 100644 --- a/fjs/types/list/types.ts +++ b/fjs/types/list/types.ts @@ -1,7 +1,5 @@ /** * Types for the immutable list data structure. - * - * @module */ import type { Nullable } from '../nullable/types.ts' diff --git a/fjs/types/nibble_set/types.ts b/fjs/types/nibble_set/types.ts index e83e40de7..5ebf9638a 100644 --- a/fjs/types/nibble_set/types.ts +++ b/fjs/types/nibble_set/types.ts @@ -1,7 +1,5 @@ /** * Types for compact 4-bit membership tracking. - * - * @module */ /** A set of nibbles as a 16-bit mask. JSON-serializable. */ diff --git a/fjs/types/nominal/types.ts b/fjs/types/nominal/types.ts index 53bd6813d..7d55b1a42 100644 --- a/fjs/types/nominal/types.ts +++ b/fjs/types/nominal/types.ts @@ -1,7 +1,5 @@ /** * Types for nominal typing (branded TypeScript types). - * - * @module */ /** diff --git a/fjs/types/nullable/types.ts b/fjs/types/nullable/types.ts index 81b3ce6a2..1708517d6 100644 --- a/fjs/types/nullable/types.ts +++ b/fjs/types/nullable/types.ts @@ -1,7 +1,5 @@ /** * Types for nullable (`null`) value handling. - * - * @module */ export type Nullable = T | null diff --git a/fjs/types/object/types.ts b/fjs/types/object/types.ts index 4d0c3d0d9..9ef88cb95 100644 --- a/fjs/types/object/types.ts +++ b/fjs/types/object/types.ts @@ -2,8 +2,6 @@ * Types for plain-object helpers: the `OptionalMap`/`RequiredMap`/`StringMap` * record shapes and `Entry`, and the `OneKey`/`SingleProperty`/`NotUnion` * utility types. - * - * @module */ /** A record over the keys of `K`, each value possibly missing at runtime. */ diff --git a/fjs/types/option/types.ts b/fjs/types/option/types.ts index fc7937afb..3ffb4c49f 100644 --- a/fjs/types/option/types.ts +++ b/fjs/types/option/types.ts @@ -1,7 +1,5 @@ /** * Optional tuple-based value representation. - * - * @module */ /** diff --git a/fjs/types/ordered_map/types.ts b/fjs/types/ordered_map/types.ts index 22e15922e..0a248baef 100644 --- a/fjs/types/ordered_map/types.ts +++ b/fjs/types/ordered_map/types.ts @@ -1,7 +1,5 @@ /** * Types for the ordered map data structure. - * - * @module */ import type { Tree } from '../btree/types/types.ts' diff --git a/fjs/types/patricia_trie/types.ts b/fjs/types/patricia_trie/types.ts index cb36cf4e1..f93715aa8 100644 --- a/fjs/types/patricia_trie/types.ts +++ b/fjs/types/patricia_trie/types.ts @@ -1,7 +1,5 @@ /** * Types for the streaming Patricia trie. - * - * @module */ /** diff --git a/fjs/types/phantom/types.ts b/fjs/types/phantom/types.ts index ed13afe80..3aac11b2f 100644 --- a/fjs/types/phantom/types.ts +++ b/fjs/types/phantom/types.ts @@ -5,8 +5,6 @@ * The phantom field uses a unique symbol key so it is excluded from string index * signatures (`{ readonly [K in string]: ... }`), making `Phantom` valid * for any `S` regardless of its index signature constraints. - * - * @module */ declare const phantomKey: unique symbol diff --git a/fjs/types/prime_field/types.ts b/fjs/types/prime_field/types.ts index e09c3b81b..01df29dfd 100644 --- a/fjs/types/prime_field/types.ts +++ b/fjs/types/prime_field/types.ts @@ -1,7 +1,5 @@ /** * Types for prime field arithmetic over `bigint`. - * - * @module */ import type { Reduce, Unary } from '../bigint/types.ts' diff --git a/fjs/types/range/types.ts b/fjs/types/range/types.ts index cc0da8f6c..5bf8a6ac0 100644 --- a/fjs/types/range/types.ts +++ b/fjs/types/range/types.ts @@ -1,7 +1,5 @@ /** * Range and interval types for numeric boundaries. - * - * @module */ export type Range = readonly [number, number] diff --git a/fjs/types/range_map/types.ts b/fjs/types/range_map/types.ts index d60b59b24..ae61dd929 100644 --- a/fjs/types/range_map/types.ts +++ b/fjs/types/range_map/types.ts @@ -1,7 +1,5 @@ /** * Types for managing and merging range maps. - * - * @module */ import type { Equal, Reduce } from '../function/operator/types.ts' diff --git a/fjs/types/result/types.ts b/fjs/types/result/types.ts index 3a03999b8..6878093d4 100644 --- a/fjs/types/result/types.ts +++ b/fjs/types/result/types.ts @@ -1,7 +1,5 @@ /** * Types for representing operations that can succeed or fail. - * - * @module */ /** diff --git a/fjs/types/sorted_list/types.ts b/fjs/types/sorted_list/types.ts index bad600921..49a558f60 100644 --- a/fjs/types/sorted_list/types.ts +++ b/fjs/types/sorted_list/types.ts @@ -1,7 +1,5 @@ /** * Types for sorted immutable lists and their merge operations. - * - * @module */ import type { Sign } from '../function/compare/types.ts' diff --git a/fjs/types/sorted_set/types.ts b/fjs/types/sorted_set/types.ts index f2583cc3d..2e7abfd75 100644 --- a/fjs/types/sorted_set/types.ts +++ b/fjs/types/sorted_set/types.ts @@ -1,7 +1,5 @@ /** * Type-level API for the sorted-set module. - * - * @module */ export type SortedSet = readonly T[] diff --git a/fjs/types/string_set/types.ts b/fjs/types/string_set/types.ts index 1cb27e79f..75488433a 100644 --- a/fjs/types/string_set/types.ts +++ b/fjs/types/string_set/types.ts @@ -1,7 +1,5 @@ /** * Type-level API for the string-set module. - * - * @module */ import type { Tree } from '../btree/types/types.ts' diff --git a/fjs/types/ts/types.ts b/fjs/types/ts/types.ts index cf5cb4f8c..df1622df6 100644 --- a/fjs/types/ts/types.ts +++ b/fjs/types/ts/types.ts @@ -1,8 +1,6 @@ /** * Types for the TypeScript source emitter: the `Equal` compile-time predicate * and the `Printer` interface. - * - * @module */ import type { Assert } from '../../asserts/types.ts' From 13bd79333b78b8c68173d4afdfd1ee9435f54689 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:46:22 +0000 Subject: [PATCH 169/370] todo: give the bare-numeric issue files slug names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit todo/README.md names issue files `{slug-kebab}.md`, but 38 kept the bare number they carried in the retired `issues/` tracker — `fjs/bnf/todo/32.md`, `fjs/djs/todo/157.md`, `nanvm-lib/todo/89.md`. A number tells a reader nothing about what the issue is, which is the scannability the naming rule exists for. The number is kept as a prefix rather than dropped. These are exactly the identifiers the tree cites as `iNNN`, and most inbound links label them that way — `[i157](./157.md)`, `[i170-ci-tool-steps](./170.md)` — so dropping it would break the correspondence between a citation's label and its target, which is the rot todo/retired-issue-identifiers.md is open about. `NNN-{slug}.md` also matches the retired tracker's own `issues/NNN-{slug}.md` scheme and the files already named this way (`todo/037-language-design-map.md`, `fjs/emergent_testing/todo/028-unit-test-examples-api.md`). Numbers are zero-padded to three digits, the majority form in the tree, and the `NNN.` prefix in each heading is left alone: todo/README.md's own worked example pairs file `037-language-design-map.md` with heading `# 37.`, so the two need not agree digit for digit. All 43 files holding inbound links are rewritten. No non-markdown file referenced any of them. The link check reports no broken targets or anchors, and the retired-identifier check in todo/retired-issue-identifiers.md still reports its documented 18. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- .../{178.md => 178-cbase32-padding-into-bit-vec.md} | 0 fjs/bnf/todo/{32.md => 032-stupid-parser.md} | 0 .../todo/{42.md => 042-mixing-serializable-bnfs.md} | 0 fjs/bnf/todo/{43.md => 043-stateful-parser.md} | 0 fjs/bnf/todo/{46.md => 046-lr1-parser.md} | 0 fjs/bnf/todo/{207.md => 207-bnf-semantic-actions.md} | 0 fjs/bnf/todo/665-bnf-data-fold-children.md | 2 +- fjs/bnf/todo/parser-structure.md | 2 +- fjs/bnf/todo/unicode-rules.md | 6 +++--- fjs/ci/todo/{96.md => 096-ci-caching.md} | 0 fjs/ci/todo/{97.md => 097-smart-ca-ci.md} | 0 .../todo/{138.md => 138-lock-file-update-script.md} | 0 fjs/ci/todo/{170.md => 170-ci-tool-step-builder.md} | 0 fjs/ci/todo/{175.md => 175-ci-setup-tool-factory.md} | 6 +++--- fjs/ci/todo/65z-ci-scenario-docker.md | 2 +- fjs/ci/todo/669-ci-ubuntu-job-factory.md | 2 +- fjs/ci/todo/66a-ci-cargo-step-factory.md | 4 ++-- fjs/ci/todo/66h-ci-npm-global-install.md | 8 ++++---- .../{157.md => 157-json-djs-shared-value-machine.md} | 0 ...{196.md => 196-djs-parser-trivia-eof-handlers.md} | 10 +++++----- .../todo/{197.md => 197-djs-unknown-shape-walker.md} | 6 +++--- fjs/djs/todo/663-json-djs-tree-type.md | 4 ++-- .../todo/66e-parser-container-stack-bookkeeping.md | 4 ++-- fjs/djs/todo/compile-modules-to-edag.md | 6 +++--- fjs/djs/todo/serializer-children-helper.md | 4 ++-- fjs/djs/todo/todjs-object-fold-is-a-map.md | 2 +- fjs/effects/node/todo/spawn-effect.md | 2 +- .../todo/{194.md => 194-test-effects-design.md} | 0 .../todo/{206.md => 206-workers-as-a-sandbox.md} | 0 .../todo/{211.md => 211-reporter-modes.md} | 0 .../todo/65y-proof-asserteq-adoption.md | 2 +- fjs/emergent_testing/todo/65z-tf-test-tree-walker.md | 2 +- .../todo/661-test-runner-behavior.md | 2 +- fjs/emergent_testing/todo/run-subset-of-tests.md | 2 +- fjs/fsc/todo/{24.md => 024-create-fsc-ts.md} | 0 fjs/fsc/todo/{47.md => 047-fsc-meta-programming.md} | 0 fjs/fsc/todo/{70.md => 070-fsc-flags.md} | 0 fjs/fsc/todo/{83.md => 083-fsc-hash-comments.md} | 0 fjs/fsc/todo/66c-emit-literals-via-owner-modules.md | 4 ++-- .../todo/{174.md => 174-shared-range-map-lexer.md} | 0 fjs/js/todo/666-js-tokenizer-position-layer.md | 2 +- fjs/js/todo/667-js-tokenizer-handler-literals.md | 4 ++-- fjs/media/json/todo/bnf-grammar-single-owner.md | 2 +- fjs/media/json/todo/number-edge-cases.md | 2 +- .../{76.md => 076-serialization-mapping-once.md} | 0 .../todo/{80.md => 080-serialization-const-ref.md} | 0 .../todo/{186.md => 186-sul-id-reuse-sha2-fromv8.md} | 0 fjs/sul/todo/id-prefix-tag-factory.md | 2 +- fjs/text/code_point/todo/codepoint-type-owner.md | 2 +- ...{190.md => 190-text-code-unit-string-boundary.md} | 0 fjs/text/utf8/todo/vec-to-code-point-pipeline.md | 2 +- .../btree/todo/66f-btree-remove-mirror-merge.md | 2 +- .../{92.md => 092-nominal-msb-lsb-bit-vectors.md} | 0 .../{141.md => 141-universal-rtti-type-system.md} | 0 .../{161.md => 161-shared-keyed-btree-collection.md} | 0 ...69.md => 169-types-map-reuse-list-combinators.md} | 2 +- .../{185.md => 185-byte-set-from-bigint-mask.md} | 4 ++-- .../todo/{193.md => 193-btree-shared-path-fold.md} | 0 fjs/types/todo/bit-set-factory.md | 4 ++-- ....md => 038-bigint-multiplication-optimization.md} | 0 nanvm-lib/todo/{56.md => 056-bytecode-to-wasm.md} | 0 nanvm-lib/todo/{87.md => 087-reduce-rc-clone.md} | 0 .../todo/{89.md => 089-rust-unpack-dispatch.md} | 0 .../todo/{131.md => 131-non-panicking-allocator.md} | 0 ...59.md => 159-collapse-per-type-wrapper-traits.md} | 0 nanvm-lib/todo/65y-nanvm-conversion-macros.md | 4 ++-- nanvm-lib/todo/debug-delimited-fmt-helper.md | 2 +- nanvm-lib/todo/error-constructors.md | 2 +- nanvm-lib/todo/ivm-from-unpacked.md | 2 +- nanvm-lib/todo/primitive-coercion-dispatch.md | 2 +- spec/todo/3360-type-annotations.md | 6 +++--- todo/README.md | 4 ++-- todo/edag-spec.md | 2 +- todo/plan/capl.md | 2 +- todo/retired-issue-identifiers.md | 8 ++++---- todo/rtti-type-system.md | 12 ++++++------ 76 files changed, 78 insertions(+), 78 deletions(-) rename fjs/basen/cbase32/todo/{178.md => 178-cbase32-padding-into-bit-vec.md} (100%) rename fjs/bnf/todo/{32.md => 032-stupid-parser.md} (100%) rename fjs/bnf/todo/{42.md => 042-mixing-serializable-bnfs.md} (100%) rename fjs/bnf/todo/{43.md => 043-stateful-parser.md} (100%) rename fjs/bnf/todo/{46.md => 046-lr1-parser.md} (100%) rename fjs/bnf/todo/{207.md => 207-bnf-semantic-actions.md} (100%) rename fjs/ci/todo/{96.md => 096-ci-caching.md} (100%) rename fjs/ci/todo/{97.md => 097-smart-ca-ci.md} (100%) rename fjs/ci/todo/{138.md => 138-lock-file-update-script.md} (100%) rename fjs/ci/todo/{170.md => 170-ci-tool-step-builder.md} (100%) rename fjs/ci/todo/{175.md => 175-ci-setup-tool-factory.md} (92%) rename fjs/djs/todo/{157.md => 157-json-djs-shared-value-machine.md} (100%) rename fjs/djs/todo/{196.md => 196-djs-parser-trivia-eof-handlers.md} (93%) rename fjs/djs/todo/{197.md => 197-djs-unknown-shape-walker.md} (97%) rename fjs/emergent_testing/todo/{194.md => 194-test-effects-design.md} (100%) rename fjs/emergent_testing/todo/{206.md => 206-workers-as-a-sandbox.md} (100%) rename fjs/emergent_testing/todo/{211.md => 211-reporter-modes.md} (100%) rename fjs/fsc/todo/{24.md => 024-create-fsc-ts.md} (100%) rename fjs/fsc/todo/{47.md => 047-fsc-meta-programming.md} (100%) rename fjs/fsc/todo/{70.md => 070-fsc-flags.md} (100%) rename fjs/fsc/todo/{83.md => 083-fsc-hash-comments.md} (100%) rename fjs/js/todo/{174.md => 174-shared-range-map-lexer.md} (100%) rename fjs/sul/todo/{76.md => 076-serialization-mapping-once.md} (100%) rename fjs/sul/todo/{80.md => 080-serialization-const-ref.md} (100%) rename fjs/sul/todo/{186.md => 186-sul-id-reuse-sha2-fromv8.md} (100%) rename fjs/text/todo/{190.md => 190-text-code-unit-string-boundary.md} (100%) rename fjs/types/todo/{92.md => 092-nominal-msb-lsb-bit-vectors.md} (100%) rename fjs/types/todo/{141.md => 141-universal-rtti-type-system.md} (100%) rename fjs/types/todo/{161.md => 161-shared-keyed-btree-collection.md} (100%) rename fjs/types/todo/{169.md => 169-types-map-reuse-list-combinators.md} (97%) rename fjs/types/todo/{185.md => 185-byte-set-from-bigint-mask.md} (92%) rename fjs/types/todo/{193.md => 193-btree-shared-path-fold.md} (100%) rename nanvm-lib/todo/{38.md => 038-bigint-multiplication-optimization.md} (100%) rename nanvm-lib/todo/{56.md => 056-bytecode-to-wasm.md} (100%) rename nanvm-lib/todo/{87.md => 087-reduce-rc-clone.md} (100%) rename nanvm-lib/todo/{89.md => 089-rust-unpack-dispatch.md} (100%) rename nanvm-lib/todo/{131.md => 131-non-panicking-allocator.md} (100%) rename nanvm-lib/todo/{159.md => 159-collapse-per-type-wrapper-traits.md} (100%) diff --git a/fjs/basen/cbase32/todo/178.md b/fjs/basen/cbase32/todo/178-cbase32-padding-into-bit-vec.md similarity index 100% rename from fjs/basen/cbase32/todo/178.md rename to fjs/basen/cbase32/todo/178-cbase32-padding-into-bit-vec.md diff --git a/fjs/bnf/todo/32.md b/fjs/bnf/todo/032-stupid-parser.md similarity index 100% rename from fjs/bnf/todo/32.md rename to fjs/bnf/todo/032-stupid-parser.md diff --git a/fjs/bnf/todo/42.md b/fjs/bnf/todo/042-mixing-serializable-bnfs.md similarity index 100% rename from fjs/bnf/todo/42.md rename to fjs/bnf/todo/042-mixing-serializable-bnfs.md diff --git a/fjs/bnf/todo/43.md b/fjs/bnf/todo/043-stateful-parser.md similarity index 100% rename from fjs/bnf/todo/43.md rename to fjs/bnf/todo/043-stateful-parser.md diff --git a/fjs/bnf/todo/46.md b/fjs/bnf/todo/046-lr1-parser.md similarity index 100% rename from fjs/bnf/todo/46.md rename to fjs/bnf/todo/046-lr1-parser.md diff --git a/fjs/bnf/todo/207.md b/fjs/bnf/todo/207-bnf-semantic-actions.md similarity index 100% rename from fjs/bnf/todo/207.md rename to fjs/bnf/todo/207-bnf-semantic-actions.md diff --git a/fjs/bnf/todo/665-bnf-data-fold-children.md b/fjs/bnf/todo/665-bnf-data-fold-children.md index 09d58de2f..e7796d3a9 100644 --- a/fjs/bnf/todo/665-bnf-data-fold-children.md +++ b/fjs/bnf/todo/665-bnf-data-fold-children.md @@ -123,6 +123,6 @@ removing the four mutated `let`s. ### Related -- [i197-djs-unknown-walker](../../djs/todo/197.md) — same spirit +- [i197-djs-unknown-walker](../../djs/todo/197-djs-unknown-shape-walker.md) — same spirit (collapse several near-identical typeof/child walks onto one parameterized traversal), on the DJS value side. diff --git a/fjs/bnf/todo/parser-structure.md b/fjs/bnf/todo/parser-structure.md index 6c1f4b2f6..da1c2a754 100644 --- a/fjs/bnf/todo/parser-structure.md +++ b/fjs/bnf/todo/parser-structure.md @@ -43,4 +43,4 @@ - [GitHub issue #407](https://github.com/functionalscript/functionalscript/issues/407) — the original report. -- [46](./46.md) — the LR(1) parser that produces this AST. +- [46](./046-lr1-parser.md) — the LR(1) parser that produces this AST. diff --git a/fjs/bnf/todo/unicode-rules.md b/fjs/bnf/todo/unicode-rules.md index 0d1c305ed..2d3eb474f 100644 --- a/fjs/bnf/todo/unicode-rules.md +++ b/fjs/bnf/todo/unicode-rules.md @@ -80,7 +80,7 @@ This split changes the public design assumptions used by older open TODOs: is blocked by this task. Its implementation must import Unicode-specific construction from `fjs/bnf/unicode/module.f.mjs` and lower text literals to generic rules before they reach core BNF. -- [`fjs/bnf/todo/207.md`](./207.md) is blocked by this task. Its planned +- [`fjs/bnf/todo/207-bnf-semantic-actions.md`](./207-bnf-semantic-actions.md) is blocked by this task. Its planned split/revision must remove `string` as a generic rule kind. Unicode text helpers are constructors of ordinary generic rules rather than a distinct generic rule kind. @@ -141,7 +141,7 @@ new module boundary and final rule discriminants before implementation starts. - [ ] Update/block `fjs/media/json/todo/bnf-grammar-single-owner.md` so its JSON grammar design imports Unicode helpers from `fjs/bnf/unicode/module.f.mjs` and does not depend on raw string rules in core BNF. -- [ ] Keep `fjs/bnf/todo/207.md` blocked until it is rebased/split so `string` is +- [ ] Keep `fjs/bnf/todo/207-bnf-semantic-actions.md` blocked until it is rebased/split so `string` is no longer described as a generic rule kind; Unicode text constructors lower to ordinary generic rules before semantic evaluation. - [ ] Check `isRepeat` in `fjs/bnf/data/module.f.mjs` still holds after the @@ -179,7 +179,7 @@ new module boundary and final rule discriminants before implementation starts. another non-Unicode alphabet consumed by the generic BNF core. - [JSON BNF grammar owner](../../media/json/todo/bnf-grammar-single-owner.md) — blocked on this split and must target `bnf/unicode` for text terminals. -- [BNF semantic actions](./207.md) — blocked on this split; its rule model must +- [BNF semantic actions](./207-bnf-semantic-actions.md) — blocked on this split; its rule model must remove generic `string` before implementation. - [`../data/README.md`](../data/README.md#the-repeat-rule) — unaffected by this split; the shipped `Repeat` encoding is a data-layer string, not a functional diff --git a/fjs/ci/todo/96.md b/fjs/ci/todo/096-ci-caching.md similarity index 100% rename from fjs/ci/todo/96.md rename to fjs/ci/todo/096-ci-caching.md diff --git a/fjs/ci/todo/97.md b/fjs/ci/todo/097-smart-ca-ci.md similarity index 100% rename from fjs/ci/todo/97.md rename to fjs/ci/todo/097-smart-ca-ci.md diff --git a/fjs/ci/todo/138.md b/fjs/ci/todo/138-lock-file-update-script.md similarity index 100% rename from fjs/ci/todo/138.md rename to fjs/ci/todo/138-lock-file-update-script.md diff --git a/fjs/ci/todo/170.md b/fjs/ci/todo/170-ci-tool-step-builder.md similarity index 100% rename from fjs/ci/todo/170.md rename to fjs/ci/todo/170-ci-tool-step-builder.md diff --git a/fjs/ci/todo/175.md b/fjs/ci/todo/175-ci-setup-tool-factory.md similarity index 92% rename from fjs/ci/todo/175.md rename to fjs/ci/todo/175-ci-setup-tool-factory.md index fab50c0bc..673d5f73b 100644 --- a/fjs/ci/todo/175.md +++ b/fjs/ci/todo/175-ci-setup-tool-factory.md @@ -48,7 +48,7 @@ export const setupTool = - Five real call sites today, all shipping. - It is the textbook `AGENTS.md` case: identical shape, only data (action descriptor, version key/value) varies. -- It is **complementary to, not a duplicate of, [i170](./170.md)**. +- It is **complementary to, not a duplicate of, [i170](./170-ci-tool-step-builder.md)**. That issue extracts the *step sequence* `toolSteps(setup, cmds)` and deliberately takes the install step as a pre-built input (`ci/bun`'s per-OS `installOnWindowsArm` is why). This issue is the missing @@ -70,7 +70,7 @@ export const setupTool = ### Related -- [i170](./170.md) — the `toolSteps` step-sequence builder; this factory feeds +- [i170](./170-ci-tool-step-builder.md) — the `toolSteps` step-sequence builder; this factory feeds it. This entry used to read `i170/i171`, but the retired `i171` is not a CI issue: it was `tf: stop relying on JS function names to detect throw tests`, resolved **won't fix** with the reason recorded in `parseTestSet`'s JSDoc in @@ -79,7 +79,7 @@ export const setupTool = [66h-ci-npm-global-install](./66h-ci-npm-global-install.md) and [66a-ci-cargo-step-factory](./66a-ci-cargo-step-factory.md) already have it. - `i136` (retired; shipped as [`fjs/ci/config/module.f.mjs`](../config/module.f.mjs)), - [i138](./138.md) — tool-version lock file; the pinned versions threaded into + [i138](./138-lock-file-update-script.md) — tool-version lock file; the pinned versions threaded into `setupTool` are exactly the values that module exports. Making the lock loadable as JSON instead is [replace-npm-check-updates-with-an-internal-script](./replace-npm-check-updates-with-an-internal-script.md). diff --git a/fjs/ci/todo/65z-ci-scenario-docker.md b/fjs/ci/todo/65z-ci-scenario-docker.md index f3726ed56..634723846 100644 --- a/fjs/ci/todo/65z-ci-scenario-docker.md +++ b/fjs/ci/todo/65z-ci-scenario-docker.md @@ -104,4 +104,4 @@ Any later implementation must: - [65Z-ci-nix](65z-ci-nix.md) — declarative per-job Nix architecture. - [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md) — direct Nix implementation and prerequisite. -- [i096](96.md) — CI caching. +- [i096](096-ci-caching.md) — CI caching. diff --git a/fjs/ci/todo/669-ci-ubuntu-job-factory.md b/fjs/ci/todo/669-ci-ubuntu-job-factory.md index 2d0a3d219..b67b20b87 100644 --- a/fjs/ci/todo/669-ci-ubuntu-job-factory.md +++ b/fjs/ci/todo/669-ci-ubuntu-job-factory.md @@ -66,5 +66,5 @@ task must not add compatibility code for the deleted Playwright job. ### Related -- [i170-ci-tool-steps](./170.md) — the `MetaStep` to `Step` pipeline (`toSteps`) these +- [i170-ci-tool-steps](./170-ci-tool-step-builder.md) — the `MetaStep` to `Step` pipeline (`toSteps`) these builders wrap. diff --git a/fjs/ci/todo/66a-ci-cargo-step-factory.md b/fjs/ci/todo/66a-ci-cargo-step-factory.md index 1321da588..3edb4b1bc 100644 --- a/fjs/ci/todo/66a-ci-cargo-step-factory.md +++ b/fjs/ci/todo/66a-ci-cargo-step-factory.md @@ -104,9 +104,9 @@ factory. ### Related -- [i170-ci-tool-steps](./170.md) — the sibling DRY cleanup for the +- [i170-ci-tool-steps](./170-ci-tool-step-builder.md) — the sibling DRY cleanup for the Node version-job builders in `fjs/ci/node`. Same root cause (per-variant step builders that differ only in command flags), different module; the two are independent and could land separately. -- [i175-ci-setup-tool](./175.md), [i170-ci-tool-steps](./170.md) +- [i175-ci-setup-tool](./175-ci-setup-tool-factory.md), [i170-ci-tool-steps](./170-ci-tool-step-builder.md) — other `fjs/ci` step-builder refactors. diff --git a/fjs/ci/todo/66h-ci-npm-global-install.md b/fjs/ci/todo/66h-ci-npm-global-install.md index 4f1f46e2d..c63466191 100644 --- a/fjs/ci/todo/66h-ci-npm-global-install.md +++ b/fjs/ci/todo/66h-ci-npm-global-install.md @@ -70,8 +70,8 @@ acceptable implementation choice if those related APIs settle on that style. This remains distinct from: -- [i170](./170.md), which builds install-and-test step sequences; -- [i175](./175.md), which builds `uses`-based GitHub Actions setup steps. +- [i170](./170-ci-tool-step-builder.md), which builds install-and-test step sequences; +- [i175](./175-ci-setup-tool-factory.md), which builds `uses`-based GitHub Actions setup steps. `npmGlobalInstall` builds a `run`-based shell-install step. @@ -87,5 +87,5 @@ This remains distinct from: ### Related -- [i170](./170.md) — `toolSteps` step-sequence builder. -- [i175](./175.md) — `setupTool` for `uses`-based setup steps. +- [i170](./170-ci-tool-step-builder.md) — `toolSteps` step-sequence builder. +- [i175](./175-ci-setup-tool-factory.md) — `setupTool` for `uses`-based setup steps. diff --git a/fjs/djs/todo/157.md b/fjs/djs/todo/157-json-djs-shared-value-machine.md similarity index 100% rename from fjs/djs/todo/157.md rename to fjs/djs/todo/157-json-djs-shared-value-machine.md diff --git a/fjs/djs/todo/196.md b/fjs/djs/todo/196-djs-parser-trivia-eof-handlers.md similarity index 93% rename from fjs/djs/todo/196.md rename to fjs/djs/todo/196-djs-parser-trivia-eof-handlers.md index 35ad58ca1..b871b681c 100644 --- a/fjs/djs/todo/196.md +++ b/fjs/djs/todo/196-djs-parser-trivia-eof-handlers.md @@ -40,7 +40,7 @@ operation everywhere, but each handler hand-rolls it. tokenizer drops `ws`/`nl` upstream (`mapToken` returns `empty` for them), and there are no `//` / `/*` tokens in JSON at all. So this is strictly a DJS-side concern and orthogonal to -[i157](./157.md), which extracts the *value-level* +[i157](./157-json-djs-shared-value-machine.md), which extracts the *value-level* state machine shared with JSON. ### Proposed abstraction @@ -121,14 +121,14 @@ out of the wrapper (or use a small variant): cases that actually matter for that state. Today the grammar is buried under boilerplate. - Maintainability: if a new trivia token is added (e.g. `'#'` - comments from [i83](../../fsc/todo/83.md)), there is one place to update, + comments from [i83](../../fsc/todo/083-fsc-hash-comments.md)), there is one place to update, not 17. ### Caveats / why this is an idea, not a mechanical edit - **i157 dependency.** The value-level handlers (`parseValueOp`/`parseArrayStartOp`/…) should be extracted via - [i157](./157.md) first. After that, the trivia + [i157](./157-json-djs-shared-value-machine.md) first. After that, the trivia wrapper covers only the DJS module-framing handlers (≈ 10 sites) rather than 17. Either order works; the result is the same end state. - **The `'nl'` exception.** `parseNewLineRequiredOp` deliberately treats @@ -148,10 +148,10 @@ out of the wrapper (or use a small variant): ### Related -- [i157](./157.md) — extracting the JSON value-state +- [i157](./157-json-djs-shared-value-machine.md) — extracting the JSON value-state machine shared with DJS. Reduces the 17 handlers to ~10 before this refactor is applied. -- [i83](../../fsc/todo/83.md) — `#` comments. A successful extraction here makes +- [i83](../../fsc/todo/083-fsc-hash-comments.md) — `#` comments. A successful extraction here makes that change a one-line edit to the wrapper's trivia case list. - [i165](../../bnf/todo/layered-parser.md) — a layered tokenizer/parser design that, if adopted, would push trivia handling entirely into the diff --git a/fjs/djs/todo/197.md b/fjs/djs/todo/197-djs-unknown-shape-walker.md similarity index 97% rename from fjs/djs/todo/197.md rename to fjs/djs/todo/197-djs-unknown-shape-walker.md index c32df1074..67be1a8f7 100644 --- a/fjs/djs/todo/197.md +++ b/fjs/djs/todo/197-djs-unknown-shape-walker.md @@ -2,7 +2,7 @@ **Priority:** P3 **Status:** open -**Blocked by:** [i157](./157.md) +**Blocked by:** [i157](./157-json-djs-shared-value-machine.md) ### Problem @@ -28,7 +28,7 @@ recursion or terminal handling): | 5 | `fjs/djs/serializer/module.f.mjs:163` — `countRefsOp` | Count references for the ref table. | | 6 | `fjs/djs/ast/module.f.mjs:41` — `toDjs` | Evaluate `AstConst` → `Unknown` (over `AstConst`, a parallel shape with `'aref'`/`'cref'`/`'array'` tuples). | -[i157 §2](./157.md) covers (1)–(3) by factoring the +[i157 §2](./157-json-djs-shared-value-machine.md) covers (1)–(3) by factoring the serializer walker. This issue extends that coverage to **(4)** and **(5)** in the same serializer file, and observes that **(6)** is the same shape walk under a different name and could share machinery if the @@ -176,7 +176,7 @@ sharing the four common leaves through a base visitor. ### Related -- [i157 §2](./157.md) — the serializer walker +- [i157 §2](./157-json-djs-shared-value-machine.md) — the serializer walker factoring. This issue is its natural follow-up: same idea, two additional call sites. - i172 (retired; shipped as [`fjs/rtti/validate/`](../../rtti/validate/module.f.mjs) diff --git a/fjs/djs/todo/663-json-djs-tree-type.md b/fjs/djs/todo/663-json-djs-tree-type.md index a8291d74f..478a546fe 100644 --- a/fjs/djs/todo/663-json-djs-tree-type.md +++ b/fjs/djs/todo/663-json-djs-tree-type.md @@ -131,9 +131,9 @@ all four aliases off the generic tree and nothing else. leaf-parameterized `Tree

` with the optional object index signature, which `json.Unknown` and the extended value domain both instantiate. This task is now about sharing that shape with `djs` rather than introducing it. -- [157](./157.md) — shares JSON/DJS parser value machinery; complementary to +- [157](./157-json-djs-shared-value-machine.md) — shares JSON/DJS parser value machinery; complementary to sharing the recursive value type. -- [197](./197.md) — extracts traversal over the same `Unknown` shape. +- [197](./197-djs-unknown-shape-walker.md) — extracts traversal over the same `Unknown` shape. - `fjs/media/json/types.ts` — current JSON recursive type aliases. - `fjs/djs/types.ts` — current DJS recursive type aliases. - `fjs/media/json/serializer/module.f.mjs` — its `treeSerialize` walks diff --git a/fjs/djs/todo/66e-parser-container-stack-bookkeeping.md b/fjs/djs/todo/66e-parser-container-stack-bookkeeping.md index 0872b6d43..0c93c2665 100644 --- a/fjs/djs/todo/66e-parser-container-stack-bookkeeping.md +++ b/fjs/djs/todo/66e-parser-container-stack-bookkeeping.md @@ -131,7 +131,7 @@ already covers the pop side. The individual helpers are readable as they stand, so this is a cleanup, not a correctness fix — hence not high priority. It is worth doing when either parser is next touched, and it is a natural prerequisite for -[i157-json-djs-shared-core](./157.md): that issue wants to +[i157-json-djs-shared-core](./157-json-djs-shared-value-machine.md): that issue wants to *share one value-machine across json and djs*, and the cleaner the per-module start/end building blocks are first, the smaller the surface that shared core has to absorb. The two efforts are complementary, not overlapping — 157 removes @@ -153,7 +153,7 @@ one and can land independently of 157. ### Related -- [i157-json-djs-shared-core](./157.md) — the larger effort +- [i157-json-djs-shared-core](./157-json-djs-shared-value-machine.md) — the larger effort to share one value-machine across json and djs; this issue tidies the per-module start/end helpers it would build on. - [i165-layered-parser](../../bnf/todo/layered-parser.md) — adjacent parser-architecture diff --git a/fjs/djs/todo/compile-modules-to-edag.md b/fjs/djs/todo/compile-modules-to-edag.md index b75deb8ed..7a79c49f5 100644 --- a/fjs/djs/todo/compile-modules-to-edag.md +++ b/fjs/djs/todo/compile-modules-to-edag.md @@ -358,7 +358,7 @@ The current DJS serializer reuses JSON serialization primitives, so ordinary `JSON.stringify(number)` cannot be the DJS fallback for these values: it serializes non-finite values as `null` and loses the sign of `-0`. Add DJS-specific handling so the chosen `.f.js` spellings parse back to the exact values. If common parser/serializer -machinery is extracted, coordinate with [`157.md`](./157.md), which already owns the +machinery is extracted, coordinate with [`157-json-djs-shared-value-machine.md`](./157-json-djs-shared-value-machine.md), which already owns the JSON/DJS structural deduplication; codec policy remains separate. The exact tests must distinguish the edge cases semantically: @@ -510,7 +510,7 @@ task; see [`bound-edag-interpreter-resources.md`](./bound-edag-interpreter-resou - [ ] Add DJS-specific number serialization that the DJS parser round-trips to exactly `Infinity`, `-Infinity`, `NaN`, and `-0`; do not change the standard JSON codec's policy as a side effect of this task. -- [ ] Coordinate any shared parser/serializer extraction with [`157.md`](./157.md) +- [ ] Coordinate any shared parser/serializer extraction with [`157-json-djs-shared-value-machine.md`](./157-json-djs-shared-value-machine.md) instead of adding another duplicate JSON/DJS walker or numeric-policy layer. - [ ] Serialize the final EDAG to `.f.js` through the EDAG-producing artifact path; allow JSON output only when it preserves the EDAG completely. @@ -554,7 +554,7 @@ task; see [`bound-edag-interpreter-resources.md`](./bound-edag-interpreter-resou - [`../../media/json/todo/number-edge-cases.md`](../../media/json/todo/number-edge-cases.md) — existing owner of the standard FunctionalScript JSON policy for `-0`, `NaN`, and infinities. -- [`157.md`](./157.md) — existing JSON/DJS parser/serializer deduplication task. +- [`157-json-djs-shared-value-machine.md`](./157-json-djs-shared-value-machine.md) — existing JSON/DJS parser/serializer deduplication task. - [`../ast/types.ts`](../ast/types.ts) — current `AstModule`/`AstBody`, `aref`, `cref`, and plain-object representation to replace. - [`../ast/module.f.mjs`](../ast/module.f.mjs) — current sequential AST evaluator. diff --git a/fjs/djs/todo/serializer-children-helper.md b/fjs/djs/todo/serializer-children-helper.md index 2866e61d2..376b273d5 100644 --- a/fjs/djs/todo/serializer-children-helper.md +++ b/fjs/djs/todo/serializer-children-helper.md @@ -74,7 +74,7 @@ No behavior change: the same child lists flow into the same folds. - `fjs/djs/serializer/module.f.mjs:66-73`, `:149-175`, `:80-82` (`entryValue`). - [66e](./66e-parser-container-stack-bookkeeping.md) — the same container-kind merge on the parser side. -- [197](./197.md) — the eventual cross-function `Visitor` factory would - supersede this, but it is deferred (blocked by [157](./157.md)); this +- [197](./197-djs-unknown-shape-walker.md) — the eventual cross-function `Visitor` factory would + supersede this, but it is deferred (blocked by [157](./157-json-djs-shared-value-machine.md)); this two-line helper is independently landable now and shrinks what 197 will later absorb. diff --git a/fjs/djs/todo/todjs-object-fold-is-a-map.md b/fjs/djs/todo/todjs-object-fold-is-a-map.md index 74e669216..712bd82b4 100644 --- a/fjs/djs/todo/todjs-object-fold-is-a-map.md +++ b/fjs/djs/todo/todjs-object-fold-is-a-map.md @@ -69,7 +69,7 @@ same `toDjs(state)` transformation; only the disguise is removed. - `fjs/djs/ast/types.ts:19-21`, `:30-36`, `:48-51` and `fjs/djs/ast/module.f.mjs:18-25` — the types and code involved. -- [197](./197.md) — the cross-function `Unknown`-walker factory; it lists +- [197](./197-djs-unknown-shape-walker.md) — the cross-function `Unknown`-walker factory; it lists `toDjs` as a caveat and defers its internals, so this intra-function cleanup is independent of it. - [663](./663-json-djs-tree-type.md) — type-only; unaffected. diff --git a/fjs/effects/node/todo/spawn-effect.md b/fjs/effects/node/todo/spawn-effect.md index 0cf57e1f8..acbca7c72 100644 --- a/fjs/effects/node/todo/spawn-effect.md +++ b/fjs/effects/node/todo/spawn-effect.md @@ -243,7 +243,7 @@ Open for review before code: child has replied, which must NOT read as EOF. - [ ] **Give every hang-regression case its own deadline**, one that kills the child and fails the case. The self-hosted runner has no hard timeout - (`../../../emergent_testing/todo/206.md:43-55`), so a returning + (`../../../emergent_testing/todo/206-workers-as-a-sandbox.md:43-55`), so a returning regression would hang `fjs test` rather than redden it — a guard against hanging that hangs is worth less than no guard, because it stops the whole suite instead of one case. diff --git a/fjs/emergent_testing/todo/194.md b/fjs/emergent_testing/todo/194-test-effects-design.md similarity index 100% rename from fjs/emergent_testing/todo/194.md rename to fjs/emergent_testing/todo/194-test-effects-design.md diff --git a/fjs/emergent_testing/todo/206.md b/fjs/emergent_testing/todo/206-workers-as-a-sandbox.md similarity index 100% rename from fjs/emergent_testing/todo/206.md rename to fjs/emergent_testing/todo/206-workers-as-a-sandbox.md diff --git a/fjs/emergent_testing/todo/211.md b/fjs/emergent_testing/todo/211-reporter-modes.md similarity index 100% rename from fjs/emergent_testing/todo/211.md rename to fjs/emergent_testing/todo/211-reporter-modes.md diff --git a/fjs/emergent_testing/todo/65y-proof-asserteq-adoption.md b/fjs/emergent_testing/todo/65y-proof-asserteq-adoption.md index 8c4c62dc6..105aadbb8 100644 --- a/fjs/emergent_testing/todo/65y-proof-asserteq-adoption.md +++ b/fjs/emergent_testing/todo/65y-proof-asserteq-adoption.md @@ -147,7 +147,7 @@ it's by far the most common and the lowest-judgement case. - `fjs/sul/id/module.f.mjs:19`, `fjs/sul/id/proof.f.mjs:1`, `fjs/sul/proof.f.mjs:1`, `fjs/sul/level/hash/proof.f.mjs:1` — the four existing consumers, demonstrating the desired call-site shape. -- [i194](./194.md), `i65X-async-test-functions` (retired, and since shipped) — +- [i194](./194-test-effects-design.md), `i65X-async-test-functions` (retired, and since shipped) — parallel work on the test framework's effect surface. The helper story above is intentionally smaller and orthogonal; it does not touch the `Reporter`/`TestEntry`/`testAll` path. Both halves of the async diff --git a/fjs/emergent_testing/todo/65z-tf-test-tree-walker.md b/fjs/emergent_testing/todo/65z-tf-test-tree-walker.md index 622b07be3..5c884559a 100644 --- a/fjs/emergent_testing/todo/65z-tf-test-tree-walker.md +++ b/fjs/emergent_testing/todo/65z-tf-test-tree-walker.md @@ -162,7 +162,7 @@ shares the semantics rather than the obsolete Playwright registration path. - i183 — broader work on the `tf` framework; this is a structural cleanup that lands cleanly alongside it. -- [i157](../../djs/todo/157.md) — same flavour: two parallel +- [i157](../../djs/todo/157-json-djs-shared-value-machine.md) — same flavour: two parallel walkers over the same static shape, differing in the per-node action. - [browser-testing](./browser-testing.md) — browser-side execution shared by the HTML, `fjs browser-test`, and Playwright outer runners. diff --git a/fjs/emergent_testing/todo/661-test-runner-behavior.md b/fjs/emergent_testing/todo/661-test-runner-behavior.md index ab0227d0c..14e2b4cbc 100644 --- a/fjs/emergent_testing/todo/661-test-runner-behavior.md +++ b/fjs/emergent_testing/todo/661-test-runner-behavior.md @@ -59,6 +59,6 @@ reporting, but it must not recreate the removed per-proof Node registration path ### Related - i155 — original external test-runner integration issue. -- [i211](./211.md) — reporter modes for the CLI and surviving external-runner bridges. +- [i211](./211-reporter-modes.md) — reporter modes for the CLI and surviving external-runner bridges. - [browser-testing](browser-testing.md) — browser-native execution shared by the HTML UI, `fjs browser-test`, and an optional external Playwright Test adapter. diff --git a/fjs/emergent_testing/todo/run-subset-of-tests.md b/fjs/emergent_testing/todo/run-subset-of-tests.md index 477404911..54859fc1a 100644 --- a/fjs/emergent_testing/todo/run-subset-of-tests.md +++ b/fjs/emergent_testing/todo/run-subset-of-tests.md @@ -80,6 +80,6 @@ Open design questions to settle before implementing: - `fjs/emergent_testing/module.f.mjs` — `main`, `collectTests`, `testAll`. - `fjs/dev/module.f.mjs` — `loadModuleMap` / `allFiles`; the predicate hook this needs. -- [211](./211.md) — reporter modes; a filtered run's summary is a reporter +- [211](./211-reporter-modes.md) — reporter modes; a filtered run's summary is a reporter concern. - [browser-testing](./browser-testing.md) — shared browser application and outer runners. diff --git a/fjs/fsc/todo/24.md b/fjs/fsc/todo/024-create-fsc-ts.md similarity index 100% rename from fjs/fsc/todo/24.md rename to fjs/fsc/todo/024-create-fsc-ts.md diff --git a/fjs/fsc/todo/47.md b/fjs/fsc/todo/047-fsc-meta-programming.md similarity index 100% rename from fjs/fsc/todo/47.md rename to fjs/fsc/todo/047-fsc-meta-programming.md diff --git a/fjs/fsc/todo/70.md b/fjs/fsc/todo/070-fsc-flags.md similarity index 100% rename from fjs/fsc/todo/70.md rename to fjs/fsc/todo/070-fsc-flags.md diff --git a/fjs/fsc/todo/83.md b/fjs/fsc/todo/083-fsc-hash-comments.md similarity index 100% rename from fjs/fsc/todo/83.md rename to fjs/fsc/todo/083-fsc-hash-comments.md diff --git a/fjs/fsc/todo/66c-emit-literals-via-owner-modules.md b/fjs/fsc/todo/66c-emit-literals-via-owner-modules.md index 1fab08847..507c88709 100644 --- a/fjs/fsc/todo/66c-emit-literals-via-owner-modules.md +++ b/fjs/fsc/todo/66c-emit-literals-via-owner-modules.md @@ -11,7 +11,7 @@ rendering is a small, well-defined concern, and the codebase already has a natural owner for each kind. But two emitters re-spell the primitive inline instead of calling the owner, so the same one-liner exists in several places. -This is the same shape as [i190-text-char-code-boundary](../../text/todo/190.md) +This is the same shape as [i190-text-char-code-boundary](../../text/todo/190-text-code-unit-string-boundary.md) ("own the single code-unit ↔ string boundary; N modules reach into the `String` built-in directly"), applied to literal rendering. @@ -142,7 +142,7 @@ but isn't exposed in a reusable (bare-string) form. ### Related -- [i190-text-char-code-boundary](../../text/todo/190.md) — same +- [i190-text-char-code-boundary](../../text/todo/190-text-code-unit-string-boundary.md) — same "own the single boundary; stop reaching into the built-in" pattern for the char-code ↔ string conversion. - [i176-json-file-effects](../../effects/node/todo/readjsonfile-writejsonfile-helpers.md) diff --git a/fjs/js/todo/174.md b/fjs/js/todo/174-shared-range-map-lexer.md similarity index 100% rename from fjs/js/todo/174.md rename to fjs/js/todo/174-shared-range-map-lexer.md diff --git a/fjs/js/todo/666-js-tokenizer-position-layer.md b/fjs/js/todo/666-js-tokenizer-position-layer.md index 478051d44..47bee1f29 100644 --- a/fjs/js/todo/666-js-tokenizer-position-layer.md +++ b/fjs/js/todo/666-js-tokenizer-position-layer.md @@ -82,5 +82,5 @@ tokenizer's dummy-path workaround. - `fjs/js/tokenizer/module.f.mjs` — the fused operator `tokenizeWithPositionOp` (:697-707), public entry `tokenize` (:712), and the two halves the dispatch still calls, `tokenizeCharCodeOp` (:647) and `tokenizeEofOp` (:667) -- [i157](../../djs/todo/157.md) — JSON/DJS value-layer sharing; the dummy-path +- [i157](../../djs/todo/157-json-djs-shared-value-machine.md) — JSON/DJS value-layer sharing; the dummy-path workaround in `json/tokenizer` is downstream of this coupling diff --git a/fjs/js/todo/667-js-tokenizer-handler-literals.md b/fjs/js/todo/667-js-tokenizer-handler-literals.md index 27a995ab5..42d84b8c2 100644 --- a/fjs/js/todo/667-js-tokenizer-handler-literals.md +++ b/fjs/js/todo/667-js-tokenizer-handler-literals.md @@ -127,9 +127,9 @@ table pairs each with itself, so all eight simple escapes are one ### Related -- [i157](../../djs/todo/157.md) — shares the value layer above the +- [i157](../../djs/todo/157-json-djs-shared-value-machine.md) — shares the value layer above the tokenizer; this issue is purely internal to the JS lexer and independent. - [i666-js-tokenizer-position-layer](./666-js-tokenizer-position-layer.md) — a separate concern (position/metadata), orthogonal to these handler literals. -- [i174-range-map-lexer](./174.md) — the `rangeFunc`/`create` +- [i174-range-map-lexer](./174-shared-range-map-lexer.md) — the `rangeFunc`/`create` dispatch machinery these handlers plug into. diff --git a/fjs/media/json/todo/bnf-grammar-single-owner.md b/fjs/media/json/todo/bnf-grammar-single-owner.md index de950da0f..18a57642c 100644 --- a/fjs/media/json/todo/bnf-grammar-single-owner.md +++ b/fjs/media/json/todo/bnf-grammar-single-owner.md @@ -127,7 +127,7 @@ Before implementing this TODO after the blocking split: principle, already applied: `operatorTags` derives from the grammar's `operator` keys, and `wsChars`/`nlChars` feed both the grammar rules and every downstream trivia-tag check. -- [157](../../../djs/todo/157.md) — shares JSON/DJS value machinery; orthogonal to +- [157](../../../djs/todo/157-json-djs-shared-value-machine.md) — shares JSON/DJS value machinery; orthogonal to ownership of the lexical BNF grammar. - [group-fs-subdirectories-by-concern](../../../todo/group-fs-subdirectories-by-concern.md) — media-directory ownership convention followed by this placement. diff --git a/fjs/media/json/todo/number-edge-cases.md b/fjs/media/json/todo/number-edge-cases.md index ead4409a6..9ad71ed3a 100644 --- a/fjs/media/json/todo/number-edge-cases.md +++ b/fjs/media/json/todo/number-edge-cases.md @@ -110,5 +110,5 @@ or adding a separate compatible API, is deliberately deferred to P5. primitive serialization implementation to replace/self-host. - [`fjs/djs/todo/compile-modules-to-edag.md`](../../../djs/todo/compile-modules-to-edag.md) — owns DJS `.f.js` round-tripping of special number values needed by EDAG artifacts. -- [`fjs/djs/todo/157.md`](../../../djs/todo/157.md) — shared JSON/DJS parser and +- [`fjs/djs/todo/157-json-djs-shared-value-machine.md`](../../../djs/todo/157-json-djs-shared-value-machine.md) — shared JSON/DJS parser and serializer extraction; coordinate reusable machinery without merging codec policy. diff --git a/fjs/sul/todo/76.md b/fjs/sul/todo/076-serialization-mapping-once.md similarity index 100% rename from fjs/sul/todo/76.md rename to fjs/sul/todo/076-serialization-mapping-once.md diff --git a/fjs/sul/todo/80.md b/fjs/sul/todo/080-serialization-const-ref.md similarity index 100% rename from fjs/sul/todo/80.md rename to fjs/sul/todo/080-serialization-const-ref.md diff --git a/fjs/sul/todo/186.md b/fjs/sul/todo/186-sul-id-reuse-sha2-fromv8.md similarity index 100% rename from fjs/sul/todo/186.md rename to fjs/sul/todo/186-sul-id-reuse-sha2-fromv8.md diff --git a/fjs/sul/todo/id-prefix-tag-factory.md b/fjs/sul/todo/id-prefix-tag-factory.md index 8407183a5..52b0f604d 100644 --- a/fjs/sul/todo/id-prefix-tag-factory.md +++ b/fjs/sul/todo/id-prefix-tag-factory.md @@ -57,5 +57,5 @@ so, since it is what makes `isRaw` false for hash ids. ### Related -- [186](./186.md), [66m-sul-literal-level-reuse](./66m-sul-literal-level-reuse.md) — +- [186](./186-sul-id-reuse-sha2-fromv8.md), [66m-sul-literal-level-reuse](./66m-sul-literal-level-reuse.md) — neighboring sul reuse work; this one is local to `sul/id` and independent. diff --git a/fjs/text/code_point/todo/codepoint-type-owner.md b/fjs/text/code_point/todo/codepoint-type-owner.md index 15141d1cc..fc286c2d1 100644 --- a/fjs/text/code_point/todo/codepoint-type-owner.md +++ b/fjs/text/code_point/todo/codepoint-type-owner.md @@ -63,7 +63,7 @@ Then: ### Related -- [190](../../todo/190.md) — the code-unit/code-point ↔ string *value* +- [190](../../todo/190-text-code-unit-string-boundary.md) — the code-unit/code-point ↔ string *value* boundary; this issue is the *type* boundary, complementary. - `fjs/text/utf8/todo/error-tag-layout-constants.md` — names the error-tag bit layout; the new type's JSDoc should link there. diff --git a/fjs/text/todo/190.md b/fjs/text/todo/190-text-code-unit-string-boundary.md similarity index 100% rename from fjs/text/todo/190.md rename to fjs/text/todo/190-text-code-unit-string-boundary.md 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 c7d0d13cd..5065593b8 100644 --- a/fjs/text/utf8/todo/vec-to-code-point-pipeline.md +++ b/fjs/text/utf8/todo/vec-to-code-point-pipeline.md @@ -66,7 +66,7 @@ with every importer updated in the same PR; a re-export left in ### Related -- [../../todo/190.md](../../todo/190.md) — single-character +- [../../todo/190-text-code-unit-string-boundary.md](../../todo/190-text-code-unit-string-boundary.md) — single-character `String.fromCharCode`/`codePointAt` boundary; this is the whole-`Vec` pipeline, a different layer. - `fjs/media/module.f.mjs:138-145` — the detector's documented re-proof of diff --git a/fjs/types/btree/todo/66f-btree-remove-mirror-merge.md b/fjs/types/btree/todo/66f-btree-remove-mirror-merge.md index 9818c182a..6d5a95a40 100644 --- a/fjs/types/btree/todo/66f-btree-remove-mirror-merge.md +++ b/fjs/types/btree/todo/66f-btree-remove-mirror-merge.md @@ -82,6 +82,6 @@ record the decision (the duplication is the accepted cost of readability). ### Related -- [i193-btree-path-fold-engine](../../todo/193.md) — shares the +- [i193-btree-path-fold-engine](../../todo/193-btree-shared-path-fold.md) — shares the cross-module `fold`/`reduceX` Path-walk engine between `set` and `remove`; this issue is the orthogonal, *within-`remove*` left/right mirror collapse. diff --git a/fjs/types/todo/92.md b/fjs/types/todo/092-nominal-msb-lsb-bit-vectors.md similarity index 100% rename from fjs/types/todo/92.md rename to fjs/types/todo/092-nominal-msb-lsb-bit-vectors.md diff --git a/fjs/types/todo/141.md b/fjs/types/todo/141-universal-rtti-type-system.md similarity index 100% rename from fjs/types/todo/141.md rename to fjs/types/todo/141-universal-rtti-type-system.md diff --git a/fjs/types/todo/161.md b/fjs/types/todo/161-shared-keyed-btree-collection.md similarity index 100% rename from fjs/types/todo/161.md rename to fjs/types/todo/161-shared-keyed-btree-collection.md diff --git a/fjs/types/todo/169.md b/fjs/types/todo/169-types-map-reuse-list-combinators.md similarity index 97% rename from fjs/types/todo/169.md rename to fjs/types/todo/169-types-map-reuse-list-combinators.md index 986992dab..5fdcecf5d 100644 --- a/fjs/types/todo/169.md +++ b/fjs/types/todo/169-types-map-reuse-list-combinators.md @@ -74,6 +74,6 @@ satisfied. ### Related -- [i161](./161.md) — the persistent-collection family; +- [i161](./161-shared-keyed-btree-collection.md) — the persistent-collection family; `map` is the JS-`Map`-backed sibling of the ordered collections discussed there. diff --git a/fjs/types/todo/185.md b/fjs/types/todo/185-byte-set-from-bigint-mask.md similarity index 92% rename from fjs/types/todo/185.md rename to fjs/types/todo/185-byte-set-from-bigint-mask.md index c1f853b42..83f897c50 100644 --- a/fjs/types/todo/185.md +++ b/fjs/types/todo/185-byte-set-from-bigint-mask.md @@ -53,7 +53,7 @@ the degenerate mask — but the clear win is `range`. - DRY: `bigint.mask` gains a genuine second consumer (it is currently used inside `bigint` and `bit_vec`); the bit-mask *arithmetic* belongs in `bigint`, not inlined in a byte-set codec. -- Separation of concerns in the spirit of [i178](../../basen/cbase32/todo/178.md) (move bit +- Separation of concerns in the spirit of [i178](../../basen/cbase32/todo/178-cbase32-padding-into-bit-vec.md) (move bit arithmetic to its natural home) — but a distinct pair (`byte_set` → `bigint` rather than `cbase32` → `bit_vec`). @@ -71,7 +71,7 @@ the degenerate mask — but the clear win is `range`. ### Related -- [i178](../../basen/cbase32/todo/178.md) — same "bit arithmetic belongs in its numeric/bit module" +- [i178](../../basen/cbase32/todo/178-cbase32-padding-into-bit-vec.md) — same "bit arithmetic belongs in its numeric/bit module" theme. - [i167](../bit_vec/module.f.mjs) — `bit_vec` re-binding flagged similarly; shipped as the shared `msb.listToVec`. diff --git a/fjs/types/todo/193.md b/fjs/types/todo/193-btree-shared-path-fold.md similarity index 100% rename from fjs/types/todo/193.md rename to fjs/types/todo/193-btree-shared-path-fold.md diff --git a/fjs/types/todo/bit-set-factory.md b/fjs/types/todo/bit-set-factory.md index 372689083..14ed5a44b 100644 --- a/fjs/types/todo/bit-set-factory.md +++ b/fjs/types/todo/bit-set-factory.md @@ -57,7 +57,7 @@ Rider: both modules inline `readonly [number, number]` for `range`'s parameter; the factory should use `Range` from `fjs/types/range/types.ts`. `has` on a `bigint` set may deserve a domain-specific override if the -generic form costs (see [185](./185.md) for the mask-based direction) — +generic form costs (see [185](./185-byte-set-from-bigint-mask.md) for the mask-based direction) — the factory can accept per-domain overrides or `byte_set` can shadow the generic `has`. @@ -72,7 +72,7 @@ generic `has`. ### Related -- [185](./185.md) — `byte_set`-internal `range`/`one` via `bigint.mask`; +- [185](./185-byte-set-from-bigint-mask.md) — `byte_set`-internal `range`/`one` via `bigint.mask`; orthogonal — the shared `range` can use `mask` internally once extracted. - `fjs/basen/module.f.mjs` — the codebase's precedent for constants-parameterized codec factories. diff --git a/nanvm-lib/todo/38.md b/nanvm-lib/todo/038-bigint-multiplication-optimization.md similarity index 100% rename from nanvm-lib/todo/38.md rename to nanvm-lib/todo/038-bigint-multiplication-optimization.md diff --git a/nanvm-lib/todo/56.md b/nanvm-lib/todo/056-bytecode-to-wasm.md similarity index 100% rename from nanvm-lib/todo/56.md rename to nanvm-lib/todo/056-bytecode-to-wasm.md diff --git a/nanvm-lib/todo/87.md b/nanvm-lib/todo/087-reduce-rc-clone.md similarity index 100% rename from nanvm-lib/todo/87.md rename to nanvm-lib/todo/087-reduce-rc-clone.md diff --git a/nanvm-lib/todo/89.md b/nanvm-lib/todo/089-rust-unpack-dispatch.md similarity index 100% rename from nanvm-lib/todo/89.md rename to nanvm-lib/todo/089-rust-unpack-dispatch.md diff --git a/nanvm-lib/todo/131.md b/nanvm-lib/todo/131-non-panicking-allocator.md similarity index 100% rename from nanvm-lib/todo/131.md rename to nanvm-lib/todo/131-non-panicking-allocator.md diff --git a/nanvm-lib/todo/159.md b/nanvm-lib/todo/159-collapse-per-type-wrapper-traits.md similarity index 100% rename from nanvm-lib/todo/159.md rename to nanvm-lib/todo/159-collapse-per-type-wrapper-traits.md diff --git a/nanvm-lib/todo/65y-nanvm-conversion-macros.md b/nanvm-lib/todo/65y-nanvm-conversion-macros.md index 65be64766..3fbd426ff 100644 --- a/nanvm-lib/todo/65y-nanvm-conversion-macros.md +++ b/nanvm-lib/todo/65y-nanvm-conversion-macros.md @@ -21,7 +21,7 @@ The remainder of this issue follows the AGENTS.md ladder `nanvm-lib/src/vm/impls/from.rs` and `nanvm-lib/src/vm/impls/try_from.rs` each carry a per-VM-wrapper impl set that is byte-identical modulo a variant name and a type argument. These are the same kind of nominal- -newtype repetition that [i159](./159.md) +newtype repetition that [i159](./159-collapse-per-type-wrapper-traits.md) addresses for `SizedIndex` / `Index` / `PartialEq`, but the conversion traits are **not covered** there. @@ -242,7 +242,7 @@ rejected. ### Related -- [i159](./159.md) — the same boilerplate- +- [i159](./159-collapse-per-type-wrapper-traits.md) — the same boilerplate- collapse exercise for `SizedIndex` / `Index` / `PartialEq`. That issue currently proposes `macro_rules!`; the constraint surfaced here should be applied there too — update diff --git a/nanvm-lib/todo/debug-delimited-fmt-helper.md b/nanvm-lib/todo/debug-delimited-fmt-helper.md index 88d5583f9..ef157eea2 100644 --- a/nanvm-lib/todo/debug-delimited-fmt-helper.md +++ b/nanvm-lib/todo/debug-delimited-fmt-helper.md @@ -63,5 +63,5 @@ decide with the code in front of you. ### Related -- [159](./159.md) — lists `ContainerFmt` as an "already-correct abstraction +- [159](./159-collapse-per-type-wrapper-traits.md) — lists `ContainerFmt` as an "already-correct abstraction to leave alone"; this issue is about the two impls that fail to consume it. diff --git a/nanvm-lib/todo/error-constructors.md b/nanvm-lib/todo/error-constructors.md index 12a415db0..2adced492 100644 --- a/nanvm-lib/todo/error-constructors.md +++ b/nanvm-lib/todo/error-constructors.md @@ -41,4 +41,4 @@ thrown value look like" becomes one module's decision. ### Related -- [131](131.md) — the allocator's failure channel, a different concern +- [131](131-non-panicking-allocator.md) — the allocator's failure channel, a different concern diff --git a/nanvm-lib/todo/ivm-from-unpacked.md b/nanvm-lib/todo/ivm-from-unpacked.md index d545767ea..125516d19 100644 --- a/nanvm-lib/todo/ivm-from-unpacked.md +++ b/nanvm-lib/todo/ivm-from-unpacked.md @@ -70,5 +70,5 @@ registered. - [65Y-nanvm-conversion-macros](./65y-nanvm-conversion-macros.md) — targets the `From for Unpacked` / `TryFrom` copies themselves; complementary, and both reduce the per-variant registration count. -- [159](./159.md) — the wrapper-trait boilerplate; same spirit at the +- [159](./159-collapse-per-type-wrapper-traits.md) — the wrapper-trait boilerplate; same spirit at the container layer. diff --git a/nanvm-lib/todo/primitive-coercion-dispatch.md b/nanvm-lib/todo/primitive-coercion-dispatch.md index d0b68d58d..c5788da43 100644 --- a/nanvm-lib/todo/primitive-coercion-dispatch.md +++ b/nanvm-lib/todo/primitive-coercion-dispatch.md @@ -79,7 +79,7 @@ still leaves layers 1–2 duplicated but is a one-line change per file. ### Related -- [159](./159.md) — wrapper-trait boilerplate cluster; its Notes flag the +- [159](./159-collapse-per-type-wrapper-traits.md) — wrapper-trait boilerplate cluster; its Notes flag the `string_coercion.rs` copy. Update that note when this lands. - [65y-nanvm-conversion-macros.md](./65y-nanvm-conversion-macros.md) — adjacent conversion boilerplate; different site. diff --git a/spec/todo/3360-type-annotations.md b/spec/todo/3360-type-annotations.md index 2bb89328d..cc38b0713 100644 --- a/spec/todo/3360-type-annotations.md +++ b/spec/todo/3360-type-annotations.md @@ -29,7 +29,7 @@ it. **Evaluating and checking an annotation** depends on the compiler being able to load and run a module as meta-programming -([`fjs/fsc/todo/47.md`](../../fjs/fsc/todo/47.md)). Recognizing one does not: +([`fjs/fsc/todo/047-fsc-meta-programming.md`](../../fjs/fsc/todo/047-fsc-meta-programming.md)). Recognizing one does not: settling the annotation's form, matching the comment, and resolving its single identifier against the module's bindings need neither meta-programming nor the expression parser, and are stages 2–3 of @@ -165,7 +165,7 @@ TypeScript aliases out. grammar is one name, so recognizing it needs no expression grammar inside a comment, and the parser gains no new syntax surface. 2. Resolve that name to a binding in scope and evaluate **the binding** at - compile time ([`fjs/fsc/todo/47.md`](../../fjs/fsc/todo/47.md)) — ordinary + compile time ([`fjs/fsc/todo/047-fsc-meta-programming.md`](../../fjs/fsc/todo/047-fsc-meta-programming.md)) — ordinary identifier resolution, the same lookup any other reference gets. There is no "annotation expression" to evaluate. @@ -206,7 +206,7 @@ annotation form and how a name resolves — rather than a paraphrase of a stage. the `,` anchoring operation for a non-resulting computation. Without it a module whose only use of an import is in an annotation is **rejected**, so this is a prerequisite of evaluating an annotation, not a later optimization. -- [`fjs/fsc/todo/47.md`](../../fjs/fsc/todo/47.md) — the compiler loading and +- [`fjs/fsc/todo/047-fsc-meta-programming.md`](../../fjs/fsc/todo/047-fsc-meta-programming.md) — the compiler loading and running modules as meta-programming, which is what compile-time evaluation of an annotation's named binding requires. - [fjs-nanvm-integration.md](../../todo/fjs-nanvm-integration.md) and diff --git a/todo/README.md b/todo/README.md index ccea5bedf..f9d2846f5 100644 --- a/todo/README.md +++ b/todo/README.md @@ -103,7 +103,7 @@ Or it was **won't fix**, like `i171`, whose reason lives in `parseTestSet`'s JSDoc exactly as the won't-fix rule below requires; say so and cite that. Whichever it is, rewrite the citation to name it — `i143` and `i172` in -`fjs/bnf/todo/207.md` are the pattern — or delete the reference if the +`fjs/bnf/todo/207-bnf-semantic-actions.md` are the pattern — or delete the reference if the relationship no longer holds. Do **not** link one to a same-numbered GitHub issue: that number belongs to unrelated work. @@ -123,7 +123,7 @@ which is what it should mean. Those two targets are written as they would appear **from this file**, in `todo/`. Re-base them against the file you are editing rather than copying them -across — `fjs/types/todo/185.md` reaches the same module as +across — `fjs/types/todo/185-byte-set-from-bigint-mask.md` reaches the same module as `../bit_vec/module.f.mjs`. Relative paths surviving a move without being re-based is what put 105 broken links in this tree. diff --git a/todo/edag-spec.md b/todo/edag-spec.md index bfbbb54ee..650a98e1c 100644 --- a/todo/edag-spec.md +++ b/todo/edag-spec.md @@ -131,7 +131,7 @@ standard JSON numeric policy remains separate in - [`fjs/djs/todo/compile-modules-to-edag.md`](../fjs/djs/todo/compile-modules-to-edag.md) — concrete parser/module rollout for Stage 1 and Stage 2; it consumes the canonical definitions from `fjs/edag/`. -- [`fjs/djs/todo/157.md`](../fjs/djs/todo/157.md) — existing JSON/DJS +- [`fjs/djs/todo/157-json-djs-shared-value-machine.md`](../fjs/djs/todo/157-json-djs-shared-value-machine.md) — existing JSON/DJS parser/serializer structural deduplication work. - [`fjs/media/json/todo/number-edge-cases.md`](../fjs/media/json/todo/number-edge-cases.md) — existing owner of standard JSON numeric edge-case policy. diff --git a/todo/plan/capl.md b/todo/plan/capl.md index 0f95f92aa..65eb26d45 100644 --- a/todo/plan/capl.md +++ b/todo/plan/capl.md @@ -22,7 +22,7 @@ This resolves several deep problems in modern software: **Normalization removes superficial differences.** The CA compiler normalizes code before hashing: it strips comments, whitespace, and renames internal variables to canonical forms. Two versions of a package that differ only in comments produce the same hash — they are the same package. This extends to dead code elimination: unused code that differs between versions does not affect the hash of the parts that are actually used. -Other CA languages exist — Unison is the most notable — but they require learning a new language and ecosystem from scratch. Most purely functional languages also impose a static type system (Haskell, Elm, PureScript). FunctionalScript takes a different approach: a dynamic type system at the core, with type validation as a separate, pluggable layer. TypeScript serves as the default validator today. Longer term, we plan to support additional type systems better suited to FunctionalScript's CA properties — including one based on `fjs/rtti` (runtime type information), which enables type-safe validation without requiring a compile-time type checker. The RTTI data form is implemented at [`fjs/rtti/data/module.f.mjs`](../../fjs/rtti/data/module.f.mjs); the broader universal type system design is tracked in [i141](../../fjs/types/todo/141.md). A pluggable type system means different communities can bring their own type discipline without forking the language. An RTTI-based type system has a further advantage: the same language is used for programming, for validating types, and for metaprogramming — one language, one mental model. This avoids the trap of TypeScript and similar systems, where the type layer is itself a separate, accidentally Turing-complete language (people have literally run DOOM inside the TypeScript type system). Types in FunctionalScript are ordinary FunctionalScript values and functions, not a second language bolted on top. Crucially, type annotations are erased during normalization — they do not affect the content hash of the logic. This means switching type systems never requires rewriting old algorithms: the normalized code is identical whether annotated with TypeScript types, RTTI validators, or no types at all. Old and new code remain fully compatible across type system changes. FunctionalScript is a strict subset of JavaScript: any software engineer who already knows JavaScript can read and write it immediately. The CA properties come from what FunctionalScript removes (mutation, side effects, identity-based equality) rather than from new syntax or concepts. This makes adoption frictionless for the world's largest developer community. +Other CA languages exist — Unison is the most notable — but they require learning a new language and ecosystem from scratch. Most purely functional languages also impose a static type system (Haskell, Elm, PureScript). FunctionalScript takes a different approach: a dynamic type system at the core, with type validation as a separate, pluggable layer. TypeScript serves as the default validator today. Longer term, we plan to support additional type systems better suited to FunctionalScript's CA properties — including one based on `fjs/rtti` (runtime type information), which enables type-safe validation without requiring a compile-time type checker. The RTTI data form is implemented at [`fjs/rtti/data/module.f.mjs`](../../fjs/rtti/data/module.f.mjs); the broader universal type system design is tracked in [i141](../../fjs/types/todo/141-universal-rtti-type-system.md). A pluggable type system means different communities can bring their own type discipline without forking the language. An RTTI-based type system has a further advantage: the same language is used for programming, for validating types, and for metaprogramming — one language, one mental model. This avoids the trap of TypeScript and similar systems, where the type layer is itself a separate, accidentally Turing-complete language (people have literally run DOOM inside the TypeScript type system). Types in FunctionalScript are ordinary FunctionalScript values and functions, not a second language bolted on top. Crucially, type annotations are erased during normalization — they do not affect the content hash of the logic. This means switching type systems never requires rewriting old algorithms: the normalized code is identical whether annotated with TypeScript types, RTTI validators, or no types at all. Old and new code remain fully compatible across type system changes. FunctionalScript is a strict subset of JavaScript: any software engineer who already knows JavaScript can read and write it immediately. The CA properties come from what FunctionalScript removes (mutation, side effects, identity-based equality) rather than from new syntax or concepts. This makes adoption frictionless for the world's largest developer community. FunctionalScript's purely functional, side-effect-free design makes it an ideal foundation for a CA language: without mutation or identity-based equality, normalization is well-defined and deduplication is always safe. diff --git a/todo/retired-issue-identifiers.md b/todo/retired-issue-identifiers.md index 1d4286175..f61cb0c1c 100644 --- a/todo/retired-issue-identifiers.md +++ b/todo/retired-issue-identifiers.md @@ -18,10 +18,10 @@ Every cited identifier below has a file: |Identifier|Retired file|Status when deleted|Bare citations| |-|-|-|-| -|`i149`|`issues/149-sandbox.md`|—|`emergent_testing/todo/206.md` ×1| -|`i155`|`issues/155-test-runner-integration.md`|—|`emergent_testing/todo/211.md` ×1, `661-test-runner-behavior.md` ×1| -|`i163`|`issues/163-reporter-test-method.md`|open|`emergent_testing/todo/211.md` ×2| -|`i183`|`issues/183-tf-framework-scenario-tests.md`|open|`emergent_testing/todo/206.md` ×1, `65y-proof-asserteq-adoption.md` ×1, `65z-singleton-effect.md` ×1, `65z-tf-test-tree-walker.md` ×1| +|`i149`|`issues/149-sandbox.md`|—|`emergent_testing/todo/206-workers-as-a-sandbox.md` ×1| +|`i155`|`issues/155-test-runner-integration.md`|—|`emergent_testing/todo/211-reporter-modes.md` ×1, `661-test-runner-behavior.md` ×1| +|`i163`|`issues/163-reporter-test-method.md`|open|`emergent_testing/todo/211-reporter-modes.md` ×2| +|`i183`|`issues/183-tf-framework-scenario-tests.md`|open|`emergent_testing/todo/206-workers-as-a-sandbox.md` ×1, `65y-proof-asserteq-adoption.md` ×1, `65z-singleton-effect.md` ×1, `65z-tf-test-tree-walker.md` ×1| |`i189`|`issues/189-asn1-decode-all-unfold.md`|done|`fjs/asn.1/todo/65z-asn1-tag-codec-table.md` ×1| |`i180-sorted-set-intersect-symmetry`|`issues/180-…`|done|`fjs/types/todo/66b-sorted-list-cmp-reduce-factory.md` ×1| |`i662`|`issues/662-rtti-ts-printer-visit.md`|open|`fjs/types/todo/66d-ts-printer-tuple-readonly-fold.md` ×1| diff --git a/todo/rtti-type-system.md b/todo/rtti-type-system.md index 1fe7ac3ca..5573d5633 100644 --- a/todo/rtti-type-system.md +++ b/todo/rtti-type-system.md @@ -638,7 +638,7 @@ it as scoped to the object shapes TypeScript can name. | TypeScript emission | [`ts/module.f.mjs`](../fjs/rtti/ts/module.f.mjs) | done as a printer — but it and `Ts<>` disagree on `unknown` and on tuple openness, by its own doc comment, so it is not yet a faithful `.d.ts` generator | | Compile-time bridge | `Ts` in [`ts/types.ts`](../fjs/rtti/ts/types.ts) | done, and transitional — see Problem | | Annotation convention | — | not started | -| Compile-time evaluation | [`fjs/fsc/todo/47.md`](../fjs/fsc/todo/47.md) | not started | +| Compile-time evaluation | [`fjs/fsc/todo/047-fsc-meta-programming.md`](../fjs/fsc/todo/047-fsc-meta-programming.md) | not started | | Inference | [type inference](../spec/todo/3370-type-inference.md) | not started — most of the work | | Function schemas | [668-rtti-function-types](../fjs/rtti/todo/668-rtti-function-types.md) | not started — and **nearly half** the tree's JSDoc type bodies are function types (~46% when measured in review of #1719; counts drift, so re-measure rather than cite this), so it gates a large share of stage 11 | | Generic schemas | the eDSL itself | **value layer done** — a schema-to-schema function needs no feature; only `.d.ts` / `Ts<>` rendering is missing | @@ -862,7 +862,7 @@ are stated instead: - **1's renderer half** can start today; its declaration-emission half needs a schema for every export, so it waits for stage 6 or an explicit manifest. - **3 onward** are gated on the compiler; **4 onward** additionally on - compile-time evaluation ([`fjs/fsc/todo/47.md`](../fjs/fsc/todo/47.md)). + compile-time evaluation ([`fjs/fsc/todo/047-fsc-meta-programming.md`](../fjs/fsc/todo/047-fsc-meta-programming.md)). - **7 splits, and the halves sit on either side of 6.** 7 as one unit is a cycle: 6's general form needs a function case in RTTI, while 7's definition-checking needs the body's *inferred* result, which is 6. The seam @@ -1020,7 +1020,7 @@ are stated instead: cleaner than inspecting the body's first character; neither adds a grammar, and neither needs the expression parser. - [ ] **4. Evaluate an annotation at compile time** - ([`fjs/fsc/todo/47.md`](../fjs/fsc/todo/47.md)) — the binding the name + ([`fjs/fsc/todo/047-fsc-meta-programming.md`](../fjs/fsc/todo/047-fsc-meta-programming.md)) — the binding the name resolves to must be reducible to a schema value, and the error when it is not is a compile error. @@ -1033,7 +1033,7 @@ are stated instead: anything; may throw, which must become a diagnostic rather than a compiler crash; and may be effectful, in which case it runs with whatever privileges the compiler has. - [`fjs/fsc/todo/47.md`](../fjs/fsc/todo/47.md) does not state a policy + [`fjs/fsc/todo/047-fsc-meta-programming.md`](../fjs/fsc/todo/047-fsc-meta-programming.md) does not state a policy today. So this stage needs the same answer stage 10 does — static schema metadata consultable without evaluating, or an explicit sandboxed-and-bounded evaluation policy — and it is the same question a @@ -1743,7 +1743,7 @@ splits around inference, so the runnable order is 668's representation half **Design background:** -- [141](../fjs/types/todo/141.md) — the earlier, more abstract form of this idea: +- [141](../fjs/types/todo/141-universal-rtti-type-system.md) — the earlier, more abstract form of this idea: a `TypeSystem` interface with `equal`/`subset`, and a parser recognizing `Ts`. `subset` shipped in [`rtti/data`](../fjs/rtti/data/module.f.mjs); the parser half is this @@ -1776,7 +1776,7 @@ splits around inference, so the runnable order is 668's representation half **Depends on:** -- [`fjs/fsc/todo/47.md`](../fjs/fsc/todo/47.md) — the compiler loading and +- [`fjs/fsc/todo/047-fsc-meta-programming.md`](../fjs/fsc/todo/047-fsc-meta-programming.md) — the compiler loading and running modules as meta-programming, which is what compile-time evaluation of an annotation *is*. **Stage 4 onward** needs it — stage 3 is comment recognition plus resolving one identifier against the module's bindings, which From d50a0ba34c626b9962101c7dccf128305cf19f99 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:49:53 +0000 Subject: [PATCH 170/370] edag/todo: require same-change hole rejection, with two verified mechanisms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #1755: leaving 'where hole rejection lives' as an open task would let the migration ship a validation regression, which AGENTS.md forbids deferring. The task is now a hard gate with two acceptable mechanisms: arity-split unions (verified — closedness by length rejects every hole with no rtti change), or an rtti rule that tuple absence is the array ending before the position, filed and landed first. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/todo/option-terminated-lambdas.md | 61 ++++++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md index a62f48334..9af930a6b 100644 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ b/fjs/edag/todo/option-terminated-lambdas.md @@ -89,29 +89,54 @@ row above a genuine type error: elements to store and hash. - "The chain ends" is spelled as absence, which is what `option` is for. -### The one widening: trailing holes +### Trailing holes must be rejected in the same change `option` admits a hole as absence, so the sparse `['.', a, 'b', ,]` — length 4, index 3 a hole — **also validates** (verified), a second spelling with a second hash for the same function, where today's required-`null` schema -rejects every hole. Contained, but real: - -- FunctionalScript cannot produce it: "Two adjacent commas are not an elision: - an array has no holes" ([`../../../spec/README.md`](../../../spec/README.md), - Arrays). Only a host-JS producer can spell it. -- A hole even *evaluates* identically to absence (reading it yields - `undefined`), so the leak is canonicality-only, the same class as the - identity-dependent rules `validate` already leaves to the Stage 2 validator - (see Caveats in [`../README.md`](../README.md)). "No holes" joins that list — - or rtti grows a no-holes rule for validated containers, defensible on its - own since a DJS value is never sparse. +rejects every hole. FunctionalScript cannot produce it — "Two adjacent commas +are not an elision: an array has no holes" +([`../../../spec/README.md`](../../../spec/README.md), Arrays) — and a hole +even *evaluates* identically to absence (reading it yields `undefined`), so +the leak is canonicality-only. It is still a validation regression against +today's schema, and a regression may not be deferred behind a todo +([`AGENTS.md`](../../../AGENTS.md) §1, "Merge the knowledge"): the migration +does not land unless the same change keeps `validate(exp)` rejecting a +trailing hole, pinned in the proofs. Two mechanisms qualify, either is +acceptable: + +1. **Arity-split unions, no `option` at all** (verified against the real + `validate`, no rtti change needed). Each node or step whose continuation + may end is a union of its two closed arities — + `or(['.', exp, index], ['.', exp, index, propertyLambda])`, and likewise + per step — so absence is spelled by the shorter tuple and a hole matches + neither arm: the 3-arity arm rejects length 4, the 4-arity arm has no + `option` and rejects the absent member. `['.', a, 'b', ,]` and + `['|()', c, ,]` both reject; every acceptance row in the table above is + unchanged. Costs: each such kind doubles its union arms, the shared prefix + is written twice, and the `AbsentOr`/`CheckRaw` machinery drops out + (hand-written types are plain unions of exact tuples, no optional + elements). +2. **An rtti rule first: absence in a tuple is the array ending before the + position, never a hole** — the const- and rest-tuple validators' absent + branch additionally requires the index to lie at or past `value.length`. + One condition, and it aligns the readers' array domain with DJS, which has + no sparse arrays — the same direction as + [`data-validate-admits-non-djs-values`](../../rtti/todo/data-validate-admits-non-djs-values.md). + But it reverses documented rtti behavior (`option`'s "position 0 may be a + hole"; `_InteriorTs` rendering interior absence as "what reading a hole + gives") across all three readers and the printer, so it needs its own rtti + issue and lands **before** this migration, which then keeps the `option` + spelling and machinery described above. ## Proposal Replace the `null` member of all three lambda schemas with `option`, drop the terminals' third operand, and let the continuation positions of `dot`, -`optionDot`, `optionCall` and the steps end by absence. Code changes are -small; the bulk is mechanical respelling of proofs and prose. +`optionDot`, `optionCall` and the steps end by absence — spelled with +`option` under mechanism 2 above, or as arity-split unions under mechanism 1 +(same accepted values either way). Code changes are small; the bulk is +mechanical respelling of proofs and prose. [amnesia](../amnesia/module.f.mjs) barely changes: its four `k === null` checks become `k === undefined`, since reading the continuation position of a @@ -131,9 +156,11 @@ present value there. smuggled continuation on a terminal; the `unspellable` family list holds - [ ] `../README.md`: node and spelling tables; "Terminals state their `null`" inverts into "closedness by length rejects a smuggled - continuation"; "The cost" shrinks; Caveats gains the trailing-hole note -- [ ] decide where hole rejection lives: an edag caveat deferred to the - Stage 2 validator, or a no-holes rule in rtti + continuation"; "The cost" shrinks +- [ ] reject trailing holes **in the same change** — mechanism 1 + (arity-split unions) or mechanism 2 (the rtti past-the-end rule, filed and + landed first) above; pin `['.', a, 'b', ,]` and `['|()', c, ,]` rejecting + in `../proof.f.mjs` ## Related From 563ac09961e7d37b3fd24891f2a76a2c80c025c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:52:42 +0000 Subject: [PATCH 171/370] todo: retire three resolved issues and document the one kept as a record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit todo/README.md requires a done or won't-fix issue to be deleted once its reasoning lives somewhere durable. Five files had a resolved status and were still in the tree; three are retired here, and the rule grows the category the other two actually need. `prefix-then-rest-tuple` (won't fix) had nothing citing it, but its decision was recorded nowhere else: that `edag`'s nested `['[]', [elem, …]]` is the chosen representation rather than a workaround for a shape rtti cannot spell, because an rtti `Tuple` pins one schema per position and growing the `Type` ADT to match the spec's flat spelling has no other consumer here. That now sits on `array`, where someone comparing the code against the spec's structural-operations table will hit it, with `object` pointing at it. `data-tosequence-reuse` and `operator-test-operation-model` (both irrelevant) were superseded by issues that already carry their content — `unicode-rules` and `reuse-edag-operators`. Their seven citations are restated in the `retired` form, and `unicode-rules`'s task to keep the superseded file around goes with it. `io-effect-migration` stays. It is done, but `fjs/effects/README.md` and `spec/todo/io-effects.md` cite it for the staged rationale behind the error channel, which no surviving file states. todo/README.md had no category for that, so the file read as a rule violation while arguing in its own text that it should stay — the gap was in the rule. The category is now written down, deliberately narrow: it applies only where live documents cite the issue for something nothing else says, and requires the status to admit what the file is. `todo/inline-type-casts.md` is untouched pending review — despite an `implemented` status it is a live reference document with 84 open sites and 20 inbound citations, not a resolved issue. `npx tsc` is clean and the suite passes 3513/3513. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- fjs/bnf/todo/data-tosequence-reuse.md | 40 ------------- fjs/bnf/todo/unicode-rules.md | 16 +++--- fjs/edag/module.f.mjs | 11 ++++ fjs/nanvm/todo/corpus-eliminators.md | 4 +- fjs/nanvm/todo/reuse-edag-operators.md | 4 +- fjs/rtti/todo/prefix-then-rest-tuple.md | 36 ------------ .../todo/operator-test-operation-model.md | 57 ------------------- .../todo/replace-unary-plus-with-number.md | 5 +- todo/README.md | 9 +++ 9 files changed, 34 insertions(+), 148 deletions(-) delete mode 100644 fjs/bnf/todo/data-tosequence-reuse.md delete mode 100644 fjs/rtti/todo/prefix-then-rest-tuple.md delete mode 100644 nanvm-lib/todo/operator-test-operation-model.md diff --git a/fjs/bnf/todo/data-tosequence-reuse.md b/fjs/bnf/todo/data-tosequence-reuse.md deleted file mode 100644 index 039f7eecb..000000000 --- a/fjs/bnf/todo/data-tosequence-reuse.md +++ /dev/null @@ -1,40 +0,0 @@ -## data-tosequence-reuse. `bnf/data` re-implements `toSequence` - -**Priority:** P5 -**Status:** irrelevant -**Superseded by:** [Separate alphabet-specific BNF helpers](./unicode-rules.md) - -### Why irrelevant - -This TODO proposed preserving `bnf/data`'s generic `string` rule case and reusing -`toSequence` from `fjs/bnf/module.f.mjs` to implement its Unicode expansion. - -The alphabet-specific BNF split intentionally removes that architecture instead: - -- `string` is removed from the generic `DataRule` / `Rule` representation; -- `fjs/bnf/data/module.f.mjs` no longer interprets strings as Unicode code points; -- `toSequence` moves to `fjs/bnf/unicode/module.f.mjs` as an alphabet-specific - construction helper; -- Unicode helpers lower strings to ordinary generic rules before they reach - `bnf/data`. - -Therefore there is no remaining duplicate `toSequence` implementation to reuse in -`bnf/data`. Implementing this TODO first would create work that the alphabet split -immediately removes, while implementing it afterward would no longer make sense. - -### Historical proposal - -The original proposal was to import `toSequence` in `fjs/bnf/data/module.f.mjs`, -replace the `'string'` case body with `sequence(toSequence(dr))`, and delete the -local duplicate Unicode-conversion helpers/imports. - -Do not implement that proposal. Implement -[Separate alphabet-specific BNF helpers](./unicode-rules.md) instead. - -### Related - -- [Separate alphabet-specific BNF helpers](./unicode-rules.md) — removes the - generic string-expansion path that motivated this TODO. -- `AGENTS.md` — "When a sibling module already has the type or helper you need, - import it." The general rule still applies; this specific duplication disappears - with the new BNF module boundary. diff --git a/fjs/bnf/todo/unicode-rules.md b/fjs/bnf/todo/unicode-rules.md index 2d3eb474f..aab03306b 100644 --- a/fjs/bnf/todo/unicode-rules.md +++ b/fjs/bnf/todo/unicode-rules.md @@ -107,14 +107,14 @@ This split changes the public design assumptions used by older open TODOs: terminals with core `range('--')` / `range('09')` and assumes `testlib.f.mjs` obtains those helpers from `./module.f.mjs`; after the split, fixture construction must import the Unicode adapter while descent/LL1 remain generic consumers. -- [`fjs/bnf/todo/data-tosequence-reuse.md`](./data-tosequence-reuse.md) is - **irrelevant because it is superseded by this task**. It proposed preserving +- `data-tosequence-reuse` (retired; **superseded by this task**, and deleted + with its reason recorded here) proposed preserving `bnf/data`'s string case and reusing core `toSequence`; this task removes that string case and moves `toSequence` to the Unicode adapter instead, so there is no duplicate generic string-expansion implementation left to reuse. -Do not implement these older designs against the pre-split API. The irrelevant -`data-tosequence-reuse.md` should not be implemented at all; when the other TODOs +Do not implement these older designs against the pre-split API. The retired +`data-tosequence-reuse` proposal should not be implemented at all; when the other TODOs are next revised/split, update their status/dependency headers and examples to the new module boundary and final rule discriminants before implementation starts. @@ -156,8 +156,6 @@ new module boundary and final rule discriminants before implementation starts. - [ ] Keep `fjs/bnf/todo/proof-recognizer-and-fixtures.md` blocked on this split; rebase its shared text fixtures/testlib imports on `fjs/bnf/unicode/module.f.mjs` before implementing the extraction. -- [ ] Keep `fjs/bnf/todo/data-tosequence-reuse.md` irrelevant/superseded; do not - implement its old generic-string reuse proposal. - [ ] Add byte helper proofs for byte boundaries and representative binary sequences/ranges. - [ ] Move/add proof coverage so generic BNF proofs exercise abstract symbols and @@ -192,9 +190,9 @@ new module boundary and final rule discriminants before implementation starts. - [Shared recognizer/proof fixtures](./proof-recognizer-and-fixtures.md) — blocked on this split; text fixture construction moves to `bnf/unicode` while parser backends stay alphabet-agnostic. -- [Reuse `toSequence` in BNF data](./data-tosequence-reuse.md) — irrelevant because - it is superseded by this split; generic BNF data no longer performs Unicode - string expansion. +- data-tosequence-reuse (retired; superseded by this split) — reusing core + `toSequence` in `bnf/data`; generic BNF data no longer performs Unicode + string expansion, so there is nothing left to reuse. - [`fjs/bnf/module.f.mjs`](../module.f.mjs) — currently mixes generic and Unicode rule construction. - [`fjs/bnf/data/module.f.mjs`](../data/module.f.mjs) — currently expands string diff --git a/fjs/edag/module.f.mjs b/fjs/edag/module.f.mjs index f742bb826..74a41b533 100644 --- a/fjs/edag/module.f.mjs +++ b/fjs/edag/module.f.mjs @@ -147,6 +147,14 @@ export const items = or(exp, spread) * [exp0, exp1] * [exp0, ...exp1] * ``` + * + * The variadic part is nested one position deep — `['[]', [elem, elem, …]]` + * — where the spec's structural-operations table writes it flat, + * `['[]', ...elements]`. That is the decided representation, not a + * workaround: an rtti `Tuple` pins one schema per position, so a fixed + * prefix followed by a homogeneous rest cannot be spread inline into a + * bigger `Const` tuple, and growing the `Type` ADT to spell it flat has no + * other consumer in this repository. `object` nests for the same reason. */ export const array = /** @type {const} */ (['[]', rttiArray(items)]) @@ -201,6 +209,9 @@ export const properties = or(property, spread) * spellings assign a prototype instead and lose the property. See "the * `__proto__` key" in `../../spec/README.md`. */ +// The entries nest one position deep, `['{}', [entry, entry, …]]`, rather +// than the spec's flat `['{}', ...entries]` — see `array` above for why that +// is the decided representation. export const object = /** @type {const} */ (['{}', rttiArray(properties)]) /** @typedef {Assert>} _Object */ diff --git a/fjs/nanvm/todo/corpus-eliminators.md b/fjs/nanvm/todo/corpus-eliminators.md index 4ee8d15c9..5514bc160 100644 --- a/fjs/nanvm/todo/corpus-eliminators.md +++ b/fjs/nanvm/todo/corpus-eliminators.md @@ -60,5 +60,5 @@ get one owner each. across the same three files and keeps the `Swapped` disambiguation; if it lands first, `orders`/`isThrows` should be extracted as part of that rewrite rather than separately — its step 3 tasks carry this extraction explicitly. -- `nanvm-lib/todo/operator-test-operation-model.md` — the earlier corpus - rewrite this section previously pointed at; superseded by the above. +- operator-test-operation-model (retired; superseded by the above) — the + earlier corpus rewrite this section previously pointed at. diff --git a/fjs/nanvm/todo/reuse-edag-operators.md b/fjs/nanvm/todo/reuse-edag-operators.md index 36b83b4e2..a6cdd1e4b 100644 --- a/fjs/nanvm/todo/reuse-edag-operators.md +++ b/fjs/nanvm/todo/reuse-edag-operators.md @@ -324,8 +324,8 @@ Throughout: — the interpreter and remaining-operators items this feeds. - [`../../../nanvm-lib/todo/replace-unary-plus-with-number.md`](../../../nanvm-lib/todo/replace-unary-plus-with-number.md) — retires `unaryPlus` in favor of the canonical `Number`. -- [`../../../nanvm-lib/todo/operator-test-operation-model.md`](../../../nanvm-lib/todo/operator-test-operation-model.md) - — the superseded local `[name, argsN]` model; its still-applicable +- operator-test-operation-model (retired; superseded by this issue) + — the local `[name, argsN]` model; its still-applicable requirements (stable names and `Swapped`, faithful literals in diagnostics, static arity rejection, consumer-owned mappings) are folded in above. Original reviews: [#1489 r3770780551](https://github.com/functionalscript/functionalscript/pull/1489#discussion_r3770780551), diff --git a/fjs/rtti/todo/prefix-then-rest-tuple.md b/fjs/rtti/todo/prefix-then-rest-tuple.md deleted file mode 100644 index 7b620a0b7..000000000 --- a/fjs/rtti/todo/prefix-then-rest-tuple.md +++ /dev/null @@ -1,36 +0,0 @@ -## A tuple schema with a fixed prefix and a homogeneous rest, spelled inline - -**Priority:** — -**Status:** closed — not pursuing - -### The gap - -A `Tuple` schema (`readonly Type[]`) pins one schema per position. There is no way to -write "this literal tag, then any number of further positions, all matching this one -schema" as a single `Const` tuple — `array`/`record` say exactly that, but only as -their *own* single schema position, not spread inline into a bigger tuple's remaining -slots. - -### Decided: `edag` will not chase the spec's flat spelling - -The EDAG spec's structural-operations table writes array and object constructors -flat and variadic: `['[]', ...elements]`, `['{}', ...entries]`. `edag/module.f.mjs` -cannot write that as an rtti schema (the gap above), so it nests the variadic part -one position deeper instead — `['[]', [elem, elem, ...]]`, `['{}', [entry, entry, -...]]`. - -That nested form is not a workaround standing in for the flat one — it is the decided -representation. Growing the rtti `Type` ADT to match the flat spelling has no other -motivating consumer in this codebase, so there is nothing open here to track. - -### Related - -- [`../../edag/module.f.mjs`](../../edag/module.f.mjs) — `array`/`object` use the - nested form. -- [Open containers](../README.md#open-containers) — `rest(c, r)` states a - prefix and a homogeneous tail, so the shape above is now spellable as one schema. - It is still not *inline* in a bigger `Const` tuple, which is the gap this file - describes, and `edag`'s nested form stays the decided representation either way. -- `../ts/types.ts`, `RestTs`'s doc comment — the type-level side of a related but - distinct problem: rendering an *existing* open tuple's TypeScript type, not - constructing a schema with this shape in the first place. diff --git a/nanvm-lib/todo/operator-test-operation-model.md b/nanvm-lib/todo/operator-test-operation-model.md deleted file mode 100644 index a0388eac5..000000000 --- a/nanvm-lib/todo/operator-test-operation-model.md +++ /dev/null @@ -1,57 +0,0 @@ -## operator-test-operation-model. Describe operations by syntax and arity - -**Priority:** P3 -**Status:** irrelevant — superseded by -[reuse-edag-operators](../../fjs/nanvm/todo/reuse-edag-operators.md) - -### Problem - -The shared operator corpus in `fjs/nanvm/` uses implementation-style names -(`'unaryPlus' | 'unaryMinus' | 'mul' | 'stringCoercion'`) and types `Case.args` -as `readonly Value[]`, so an operation is not connected to its operand count. -This todo, following the post-merge review of #1489, proposed fixing both with -a local semantic descriptor carrying a name and an arity: - -```ts -export type Operation = - readonly [name: string, argsN: N] // ['+', 1], ['*', 2], ['String', 1] -``` - -### Why superseded - -[`fjs/edag/`](../../fjs/edag/README.md) now ships the canonical operation -vocabulary this descriptor would have duplicated: `op1Id`/`op2Id` in -[`module.f.mjs`](../../fjs/edag/module.f.mjs) and `Op1Id`/`Op2Id` in -[`types.ts`](../../fjs/edag/types.ts). There, arity is not an annotation but -the group a tag belongs to (`op1`/`op2`), and every tag is unique — negation is -`neg`, not an arity-overloaded `-`, and there is no unary `+` at all — so the -`[name, argsN]` disambiguation scheme has nothing left to disambiguate, and a -local model would be a second vocabulary able to drift from the canonical one. - -The corpus redesign therefore reuses the EDAG definitions instead; -[reuse-edag-operators](../../fjs/nanvm/todo/reuse-edag-operators.md) carries -the plan, including this todo's still-applicable requirements: - -- semantic operator spellings instead of implementation names — now the - canonical `Op1Id`/`Op2Id` spellings; -- arity-aware case types with wrong argument counts rejected statically — now - `Case<1>`/`Case<2>` derived from the id's group; -- `commutative` restricted to binary groups; -- stable case names as proof keys, with the explicit `Swapped` disambiguation - (equal-argument commutative cases make the name, not the expression, the - unique key); -- diagnostics rendered as faithful source literals (`123` vs `123n`, `0` vs - `-0`), with explicit `Object.is`/throw forms where `===` would misdescribe - the comparison; -- consumer-owned mapping to JavaScript/Rust implementations — in particular no - `snakeCase` over punctuation tags, and no Rust identifiers leaking into the - shared data; -- `eq` (`===`) kept outside the generic group model for now. - -### Related - -- [reuse-edag-operators](../../fjs/nanvm/todo/reuse-edag-operators.md) — the - superseding plan. -- #1489 — introduced the shared operator corpus. -- #1489 review: https://github.com/functionalscript/functionalscript/pull/1489#discussion_r3770780551 -- #1489 review: https://github.com/functionalscript/functionalscript/pull/1489#discussion_r3770797058 diff --git a/nanvm-lib/todo/replace-unary-plus-with-number.md b/nanvm-lib/todo/replace-unary-plus-with-number.md index 832b05f27..1e5a36bc4 100644 --- a/nanvm-lib/todo/replace-unary-plus-with-number.md +++ b/nanvm-lib/todo/replace-unary-plus-with-number.md @@ -120,5 +120,6 @@ emits `Any::unary_plus(...)` when generating `nanvm-lib/tests/test/generated.rs` `unaryPlus` has no EDAG operation to derive its shape from. - [`numeric-operator-home.md`](./numeric-operator-home.md) — adjacent `Numeric` algebra layout, same `any/`-vs-`numeric.rs` split this touches. -- [`operator-test-operation-model.md`](./operator-test-operation-model.md) — the `Op`-union - redesign this predates; folded into `reuse-edag-operators.md`. +- operator-test-operation-model (retired; folded into + [`reuse-edag-operators.md`](../../fjs/nanvm/todo/reuse-edag-operators.md)) — the + `Op`-union redesign this predates. diff --git a/todo/README.md b/todo/README.md index f9d2846f5..e738cc607 100644 --- a/todo/README.md +++ b/todo/README.md @@ -179,6 +179,15 @@ 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. Do not leave a status-only tombstone. +Kept as the record: a resolved issue stays **only** when live documents cite it +for something no surviving file says — a migration's staged rationale, a +decision's alternatives. Say so in its `**Status:**` and name what cites it, so +a reader can tell it from an open task at a glance; +[`fjs/effects/todo/io-effect-migration.md`](../fjs/effects/todo/io-effect-migration.md) +is the worked example. This is the narrow exception, not a way to keep finished +work around: if the rationale can move into a `README.md` or JSDoc, move it and +delete the file. + ## Priority scale | Level | Meaning | From 8a2957c5b2c0e21dbb2aa86e4d7ec2d6e06c3b3e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:53:46 +0000 Subject: [PATCH 172/370] edag/todo: split the schema and type tasks by hole-rejection mechanism Review finding on #1755: the schema/type tasks prescribed the option spelling unconditionally, so following them under the arity-split mechanism would put option back at the lambda roots and reintroduce the trailing-hole regression. The mechanism choice is now the first task and the schema and type tasks are spelled per mechanism; the hole-rejection pins move into the proof task, required either way. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/todo/option-terminated-lambdas.md | 35 ++++++++++++++-------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md index 9af930a6b..b8423e8a8 100644 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ b/fjs/edag/todo/option-terminated-lambdas.md @@ -145,22 +145,33 @@ present value there. ### Tasks -- [ ] `../module.f.mjs`: `option` for `null` in the three lambda unions; - terminals become closed 2-tuples; `AbsentOr` phantom annotations plus - `CheckRaw` asserts for `_optionLambda`/`_optionPropertyLambda` -- [ ] `../types.ts`: the optional-element types above -- [ ] `../amnesia/module.f.mjs`: `k === null` → `k === undefined`; signatures - take `… | undefined` +The first task decides the shape of the two after it — they are spelled per +mechanism because mixing them reintroduces the hole: an arity-split arm whose +lambda root still carries `option` admits the absent member again. + +- [ ] pick the hole-rejection mechanism: **1** (arity-split unions, no + `option` anywhere in the chain schemas, no rtti change) or **2** (the rtti + past-the-end rule, filed as its own rtti issue and landed first, then the + `option` spelling below) +- [ ] `../module.f.mjs` — under mechanism 2: `option` for `null` in the three + lambda unions; terminals become closed 2-tuples; `AbsentOr` phantom + annotations plus `CheckRaw` asserts for + `_optionLambda`/`_optionPropertyLambda`. Under mechanism 1: every lambda + union and continuation-carrying node splits by arity instead — no `option` + member in any of them, and the phantom annotations stay plain +- [ ] `../types.ts` — under mechanism 2: the optional-element types above; + under mechanism 1: plain unions of exact tuples, one per arity, no + optional elements +- [ ] `../amnesia/module.f.mjs` (same under either mechanism): + `k === null` → `k === undefined`; signatures take `… | undefined` - [ ] `../proof.f.mjs`, `../amnesia/proof.f.mjs`: respell (~200 trailing - `null`s); add rejections for present `null`, present `undefined`, and the - smuggled continuation on a terminal; the `unspellable` family list holds + `null`s); add rejections for present `null`, present `undefined`, the + smuggled continuation on a terminal, and the trailing holes + `['.', a, 'b', ,]` and `['|()', c, ,]`; the `unspellable` family list + holds - [ ] `../README.md`: node and spelling tables; "Terminals state their `null`" inverts into "closedness by length rejects a smuggled continuation"; "The cost" shrinks -- [ ] reject trailing holes **in the same change** — mechanism 1 - (arity-split unions) or mechanism 2 (the rtti past-the-end rule, filed and - landed first) above; pin `['.', a, 'b', ,]` and `['|()', c, ,]` rejecting - in `../proof.f.mjs` ## Related From 61d8d47425157fdd9e27996e6f93d8c06f679366 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:55:27 +0000 Subject: [PATCH 173/370] effects: IoChannel is not the node standard, in the file that says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the leftover: `Effect`'s doc still called `IoChannel` "the node standard of NotImplemented | IoError" — in the file this change promotes to the host-agnostic layer, under a paragraph saying the vocabulary is not node's. The two corrections the PR body listed did not cover it. Also trims the changelog entry to the ~250-character guideline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/1753.md | 7 +++---- fjs/effects/types.ts | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/changelog/unreleased/1753.md b/changelog/unreleased/1753.md index e46b9e44f..1aa8fe757 100644 --- a/changelog/unreleased/1753.md +++ b/changelog/unreleased/1753.md @@ -1,4 +1,3 @@ -- `effects`: the vocabulary every operation is declared in — `OpResult`, - `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult`, and the `ioError` / - `toIoError` constructors — is now importable from the core module, not only - through `effects/node`, which re-exports it unchanged +- `effects`: `OpResult`, `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult` and + the `ioError` / `toIoError` constructors are now importable from the core + module, not only through `effects/node`, which re-exports them unchanged diff --git a/fjs/effects/types.ts b/fjs/effects/types.ts index 649aced21..974a02db1 100644 --- a/fjs/effects/types.ts +++ b/fjs/effects/types.ts @@ -148,8 +148,8 @@ export type IoResult = Result * **`E` defaults to {@link NotImplemented}**, the one error every operation can * answer with, so the common case is written `Effect`. An * operation's own failures extend the channel — `Effect`, that alias being the node standard of - * `NotImplemented | IoError`. + * IoChannel>`, that alias being the standard {@link IoChannel} of + * `NotImplemented | IoError` that any host's IO answers in. * * **`Effect` is a claim, not an absence.** It says this code * absorbs its own failures *here* — an MCP handler turning one into a JSON-RPC From eab016cf6fd97703bf1fd73416b52c7807c6d645 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:56:31 +0000 Subject: [PATCH 174/370] edag/todo: mechanism 2 must also renormalize the data form Review finding on #1755, confirmed against trimPrefix in fjs/rtti/data/module.f.mjs: once a hole is no member, [option] and [] denote one array set, and the data form keeps them canonically distinct today exactly because they differ on new Array(1). The prerequisite rtti issue for the past-the-end rule must therefore also specify the arraySet normalization and carry cmp/equal/subset, the data reader, and the printer - a surface mechanism 1 does not touch, which the issue now notes as an argument for mechanism 1. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/todo/option-terminated-lambdas.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md index b8423e8a8..5694f644c 100644 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ b/fjs/edag/todo/option-terminated-lambdas.md @@ -125,9 +125,22 @@ acceptable: [`data-validate-admits-non-djs-values`](../../rtti/todo/data-validate-admits-non-djs-values.md). But it reverses documented rtti behavior (`option`'s "position 0 may be a hole"; `_InteriorTs` rendering interior absence as "what reading a hole - gives") across all three readers and the printer, so it needs its own rtti - issue and lands **before** this migration, which then keeps the `option` - spelling and machinery described above. + gives") across all three readers and the printer — and it reaches the + data form's canonical algebra: once a hole is no member, `[option]` and + `[]` denote one array set, while `toData` deliberately keeps them + distinct today — `trimPrefix` in + [`../../rtti/data/module.f.mjs`](../../rtti/data/module.f.mjs) exempts + the empty `rest` exactly because the two "differ on `new Array(1)`". + Updating only the validators' absent branches would leave the data form + with two spellings of one set and its `validate`/`equal`/`subset` + disagreeing with the schema readers, so the prerequisite rtti issue must + also specify the `arraySet` normalization that collapses an + absence-admitting trailing position against an empty `rest` (one `Node` + for `[option]` and `[]`), and carry `cmp`/`equal`/`subset`, the data + reader, and the printer with it. It needs its own rtti issue and lands + **before** this migration, which then keeps the `option` spelling and + machinery described above — and the width of that surface, against + mechanism 1 touching none of it, is itself an argument for mechanism 1. ## Proposal From 121d393a6be5ce1d2a241fc53c3c355e5abe4e5e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:57:38 +0000 Subject: [PATCH 175/370] todo: Stage 2 needs a CI job that type-checks the packed artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting private.d.ts at prepack is invisible to every existing check: npx tsc reads the source private.ts, npm pack never type-checks its output, and the global installs in CI take the published CLI rather than the artifact just built. Record what makes such a job real rather than theatre — skipLibCheck off in the consumer, a consumer that imports the affected module surfaces, a directory outside the repository — plus a falsifiability task, and point the work at the packed-consumer fixture fjs/ci/todo/f-mjs-package-support.md already scopes instead of a second validation path. Also repairs the Related section: the blocked @internal todo it linked was deleted in Stage 1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/todo/separate-private-types.md | 75 ++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index ebe07e019..3c34ff1ef 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -231,6 +231,47 @@ Package validation must check semantic dependencies, not raw text: - no packed declaration semantically depends on an unshipped private type module; - a clean TypeScript consumer installed from the tarball type-checks successfully. +#### The check has to run in CI, on the packed artifact + +Deleting `private.d.ts` is invisible to every check the repository has. `npx tsc` +reads the *source* `private.ts`, so it stays green whatever `prepack` removes; +`node26` runs `npm pack` but nothing installs or type-checks the result, and the +`npm install -g functionalscript@` steps install the *published* CLI, +not the artifact just built. Stage 2 would therefore ship a claim that nothing +could falsify — the same "a sweep, not a check" gap the Stage 1 grep guard +closes. Only a consumer that reads the packed declarations can catch a +declaration left pointing at a file the package no longer carries. + +Three constraints decide whether such a job is real or theatre: + +- **`skipLibCheck` must be off in the consumer.** The repository's + `tsconfig.json` sets `skipLibCheck: true`; inherited, it stops TypeScript from + ever opening the packed `.d.mts` internals, and a dangling private reference + passes silently. The consumer needs its own `tsconfig.json` with + `skipLibCheck: false`. +- **The consumer must import the module surfaces whose declarations referenced + private types**, or the broken declaration is not in its program at all. +- **The consumer directory must be outside the repository**, so + `allowImportingTsExtensions`, `rewriteRelativeImportExtensions`, and the rest + of the repository's compiler options cannot mask a packaging bug. + +A tarball-contents assertion (no `private.d.ts` inside) belongs alongside it, but +it is a cheap complement: the semantic check is the consumer, per the rule above. + +Prefer a separate `package` job over more `node26` steps — it needs that clean +directory, it is independent of `node26`'s other invariants, and a named red +check reports what broke. Either way the job is added through the CI generator +(`fjs/ci/node/module.f.mjs`, composed in `fjs/ci/module.f.mjs`), never by editing +`.github/workflows/ci.yml`, which `npm run ci-update` regenerates. + +This fixture is already scoped in +[`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md), +where the clean packed-consumer validation was performed **manually** in +[#1520](https://github.com/functionalscript/functionalscript/pull/1520) and the +committed CI fixture is the remaining work. Stage 2 completes that fixture and +adds the private-declaration assertion to it rather than standing up a second +package-validation path. + ### Repository policy When Stage 1 is implemented: @@ -319,6 +360,20 @@ type-only and use named `import type { ... }` imports. `prepack` step. - [ ] Do not text-postprocess emitted declarations; validate semantic private dependencies and clean-consumer type checking instead. +- [ ] Add a CI job that validates the packed artifact: install the tarball into + a clean directory outside the repository, with its own `tsconfig.json` + setting `skipLibCheck: false`, and type-check a consumer that imports the + module surfaces whose declarations referenced private types. Add it + through the CI generator (`fjs/ci/node/module.f.mjs`, composed in + `fjs/ci/module.f.mjs`), not by editing `.github/workflows/ci.yml`. Prefer + a separate `package` job over more `node26` steps. Complete the fixture + already scoped in [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) + rather than adding a second package-validation path. +- [ ] Assert the tarball's contents (no `private.d.ts` inside) alongside that + job — a cheap complement to the semantic consumer check, never its + replacement. +- [ ] Prove the job can fail: with the `prepack` deletion step removed, or with + a private declaration reintroduced, the packaged consumer must go red. - [ ] Add fixtures covering packaging: retained non-semantic JSDoc `@import` comments in emitted declarations, absent private artifacts in the tarball, and a clean package consumer. @@ -365,19 +420,29 @@ type-only and use named `import type { ... }` imports. comments are allowed when they are non-semantic. - The packed artifact has no semantic dependency on an unshipped private type module, and a clean TypeScript consumer type-checks successfully. +- That consumer runs **in CI**, from the packed tarball, in a directory outside + the repository, under its own `tsconfig.json` with `skipLibCheck: false` — the + only arrangement in which a declaration pointing at a deleted `private.d.ts` + is an error rather than a silently skipped library file. +- The job is demonstrably falsifiable: removing the `prepack` deletion step, or + reintroducing a private declaration, turns it red. +- The CI job is generated from `fjs/ci/**`, so `npm run ci-update` reproduces + `.github/workflows/ci.yml` byte-identically. - `fjs/fsc/README.md` no longer needs tolerance for a shipped `private.d.ts`, since none ships, and still documents the permanent `_` contract: `_` names emitted into shipped declarations are not API. ### Related -- [`../fsc/README.md`](../fsc/README.md) — current `_` leak-tolerance policy. -- [`../../AGENTS.md`](../../AGENTS.md) — root repository policy to update. +- [`../fsc/README.md`](../fsc/README.md) — the `_` contract and the remaining + `private.d.ts` tolerance Stage 2 retires. +- [`../../AGENTS.md`](../../AGENTS.md) — root repository policy. - [`../AGENTS.md`](../AGENTS.md) — `fjs/`-specific file/dependency policy. -- [`../../todo/blocked/jsdoc-typedef-strip-internal.md`](../../todo/blocked/jsdoc-typedef-strip-internal.md) - — current wait-for-`@internal`/`stripInternal` strategy. +- [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) + — the packed-consumer CI fixture Stage 2 completes. - [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) - — upstream JSDoc typedef stripping limitation. + — upstream JSDoc typedef stripping limitation; superseded as this design's + strategy, since no authored `.mjs` declares a typedef to strip. - [`detect-unexported-types-referenced-by-exported-types.md`](./detect-unexported-types-referenced-by-exported-types.md) — related declaration-leak detection. - [`document-file-type-naming-conventions.md`](./document-file-type-naming-conventions.md) From 242f854a5c1def7bd6046854e9cf18ca0593f93e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:59:18 +0000 Subject: [PATCH 176/370] virtual: pin each converted lookup, not just the shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inheritedNameInWrites` covers the five sites the read proofs never reached — `writeFileOp`, `readBytesOp`, `writeBytesRawOp`, `extractEntity` and `insertEntityAt` — so a regression confined to one of them fails rather than hiding behind `entryOf`. Verified by reverting each site on its own: every one fails a proof. The changelog entry is trimmed to the length guideline. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BRNUuhnny5cNz1NFa5KoDV --- changelog/unreleased/1754.md | 4 +-- fjs/effects/node/virtual/proof.f.mjs | 37 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/changelog/unreleased/1754.md b/changelog/unreleased/1754.md index a88424b51..f97c6b428 100644 --- a/changelog/unreleased/1754.md +++ b/changelog/unreleased/1754.md @@ -1,4 +1,4 @@ - `effects/node/virtual`: a path naming an inherited property (`toString`, `__proto__`) is absent in every operation, rather than reading as a - `JsModule` or a directory — `access` and `readFile` claimed such a name - existed, and `readFile` threw outside the effect's channel + `JsModule` or a directory — `readFile` even threw outside the effect's + channel diff --git a/fjs/effects/node/virtual/proof.f.mjs b/fjs/effects/node/virtual/proof.f.mjs index be6fc63dc..a35a1e4de 100644 --- a/fjs/effects/node/virtual/proof.f.mjs +++ b/fjs/effects/node/virtual/proof.f.mjs @@ -515,6 +515,43 @@ export const proof = { assert(again[0] === 'error', again) assertIoCode(again[1], 'EEXIST') }, + // The writing half, which the reads above do not reach: one proof per + // remaining lookup, so a regression confined to a single operation cannot + // hide behind the shared helper. + inheritedNameInWrites: () => { + const payload = utf8('x') + // `writeFile` **creates** it: an inherited name is absent, and writing + // to an absent name is what this operation is for. Before the guard, + // `dir['toString']` was a function and the write was refused as + // "invalid file". + const [written, result] = virtual(emptyState)(writeFile('toString', payload)) + assert(result[0] === 'ok', result) + assertEq(Object.keys(written.root).join(), 'toString') + // And what comes back is the payload, not the inherited function. + const [, read] = virtual(written)(readFile('toString')) + assert(read[0] === 'ok', read) + assertEq(utf8ToString(read[1]), 'x') + // The two positional operations refuse it, neither creating nor + // reading `Object.prototype`. + const [, bytes] = virtual(emptyState)(readBytes('toString', 0, 1)) + assert(bytes[0] === 'error', bytes) + assertIoCode(bytes[1], 'ENOENT') + const [, put] = virtual(emptyState)(writeBytes('toString', 0, payload)) + assert(put[0] === 'error', put) + assertIoCode(put[1], 'ENOENT') + // `rename` reads through both halves: `extractEntity` for the source, + // `insertEntityAt` for the destination. + const [, moved] = virtual(emptyState)(rename('toString', 'a.txt')) + assert(moved[0] === 'error', moved) + assertIoCode(moved[1], 'ENOENT') + // Renaming *onto* one is an ordinary create, not an overwrite of + // whatever `Object.prototype` holds there. + /** @type {Dir} */ + const root = { 'a.txt': [vec8(0x41n)] } + const [renamed, onto] = virtual({ ...emptyState, root })(rename('a.txt', '__proto__')) + assert(onto[0] === 'ok', onto) + assertEq(Object.keys(renamed.root).join(), '__proto__') + }, statOnRegularFile: () => { /** @type {Dir} */ const root = { 'a.txt': [vec8(0x41n)] } From d91518d3d7aee6ed8c0e651b40a87835f8127421 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:59:39 +0000 Subject: [PATCH 177/370] todo: the migration record said IoError lives in effects/node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit io-effect-migration.md is done but deliberately kept, and two live documents cite it as the design record — so its statement that IoError sits "in fjs/effects/node/types.ts beside the operations it belongs to" is guidance, not just history, and this change made it false. It now records both: that the migration put them there, which was true while node's were the only operations there were, and what overturned it — effects/memory importing OpResult from the node module, and a second host unable to declare an operation without doing the same. Nothing it says about their shape or use changed, and isNotFound stayed behind. Swept the rest of the markdown for the same claim: the remaining mentions are past-tense history, statements about operations (still in node), or a released changelog. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/io-effect-migration.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/fjs/effects/todo/io-effect-migration.md b/fjs/effects/todo/io-effect-migration.md index ad398885d..5ff901367 100644 --- a/fjs/effects/todo/io-effect-migration.md +++ b/fjs/effects/todo/io-effect-migration.md @@ -303,12 +303,23 @@ from `Result` — for one that performs host IO (`Result`). `IoError` is `readonly['ioError', { code?, message }]`, a tagged tuple beside -`NotImplemented` so the shared channel stays discriminable, in -`fjs/effects/node/types.ts` beside the operations it belongs to. `toIoError` +`NotImplemented` so the shared channel stays discriminable. `toIoError` normalizes a thrown host value at the one boundary where an impure runner catches; the virtual runner reports the same shape, so a proof against the virtual filesystem stays evidence about the real one. +**Both, and the two aliases above, have since moved to `fjs/effects/types.ts`.** +This migration put them in `fjs/effects/node/types.ts`, "beside the operations +they belong to", which was true while node's were the only operations there +were. What overturned it is a second host: `effects/memory` — no host at +all — was importing `OpResult` from the node module, and a browser +interpreter could not declare an operation without doing the same. `effects/node` +re-exports all of them, so nothing this record describes about their *shape* or +their use has changed. `isNotFound` stayed behind, being about `ENOENT` +specifically. See +[node-module-layering](./node-module-layering.md), which owns that question +now. + **`Write` and `Read` stayed `OpResult`.** They are host IO and could fail (`EPIPE`), but this stage's rule for a currently-infallible handler is to wrap its output in `ok(...)`, not to invent a failure it never reported. Promoting From e8f7b1cddaf95e23320e57bfffc3b9fa18fb8909 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:02:11 +0000 Subject: [PATCH 178/370] edag/todo: interior absent bits normalize too; respell downstream designs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on #1755, both verified. Under the past-the-end rule an absent bit is realizable only in the all-optional trailing run, so interior absence becomes unobservable — [or(option, number), 3] comes to denote [number, 3] while toData keeps its absentBit — and the prerequisite rtti issue must strip it, the same trailing-run split TupleTs makes. And the respelling task now covers the active downstream designs (compile-modules-to-edag.md, interpret-edag.md, the bun-parentheses blocker) plus a repo-wide sweep, leaving released changelog entries as history. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/todo/option-terminated-lambdas.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md index 5694f644c..68ce306c2 100644 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ b/fjs/edag/todo/option-terminated-lambdas.md @@ -137,7 +137,17 @@ acceptable: also specify the `arraySet` normalization that collapses an absence-admitting trailing position against an empty `rest` (one `Node` for `[option]` and `[]`), and carry `cmp`/`equal`/`subset`, the data - reader, and the printer with it. It needs its own rtti issue and lands + reader, and the printer with it. Not only trailing, either: under the + rule, a position's absence is realizable exactly when every later + position also admits absence — an array ending before it ends before + them too — so an **interior** absent bit (any position followed by one + whose set excludes absence) becomes unobservable. `[or(option, number), + 3]`, the `option` JSDoc's own example, comes to denote the same set as + `[number, 3]` while `toData` keeps its `absentBit` and `_InteriorTs` + renders a stale `| undefined`; the normalization must strip every + unobservable interior absent bit — the same trailing-run split `TupleTs` + already makes — with the type-level and printer renderings following. + It needs its own rtti issue and lands **before** this migration, which then keeps the `option` spelling and machinery described above — and the width of that surface, against mechanism 1 touching none of it, is itself an argument for mechanism 1. @@ -185,6 +195,15 @@ lambda root still carries `option` admits the absent member again. - [ ] `../README.md`: node and spelling tables; "Terminals state their `null`" inverts into "closedness by length rejects a smuggled continuation"; "The cost" shrinks +- [ ] downstream designs and other repo-wide chain spellings: + [`../../djs/todo/compile-modules-to-edag.md`](../../djs/todo/compile-modules-to-edag.md) + and [`../../djs/todo/interpret-edag.md`](../../djs/todo/interpret-edag.md) + both prescribe `['.', object, property, null]` and `['|()', args, null]` + for stages not yet implemented, which would produce or expect invalid + EDAG after the migration; respell them, + [`../../../todo/blocked/bun-optional-chain-parentheses.md`](../../../todo/blocked/bun-optional-chain-parentheses.md), + and whatever else a sweep for chain spellings finds — released + `changelog/` entries stay as written, history rather than prescription ## Related From 5c33e089b7c09fe25c0d4d974a5934c69e119f72 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:02:40 +0000 Subject: [PATCH 179/370] todo: the two package checks need opposite negative controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the prepack deletion step leaves private.d.ts in the tarball, where every reference to it resolves — that reddens the contents assertion and leaves the consumer green, so it cannot serve as the consumer's negative control. The consumer's control is the reverse: a packed declaration depending on a private module the tarball does not carry, which resolves in-repo and dangles once packed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/todo/separate-private-types.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 3c34ff1ef..6a144f1ea 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -372,8 +372,14 @@ type-only and use named `import type { ... }` imports. - [ ] Assert the tarball's contents (no `private.d.ts` inside) alongside that job — a cheap complement to the semantic consumer check, never its replacement. -- [ ] Prove the job can fail: with the `prepack` deletion step removed, or with - a private declaration reintroduced, the packaged consumer must go red. +- [ ] Prove each half can fail, with its own negative control — they fail on + opposite inputs, so one control cannot stand for both. Removing the + `prepack` deletion step leaves `private.d.ts` *in* the tarball, where + every reference to it resolves: that reddens the contents assertion and + leaves the consumer green. The consumer's control is the reverse — a + packed declaration that references a private module the tarball does not + carry (a shipped declaration made to depend on `private.ts`, with the + deletion still running), which resolves in-repo and dangles once packed. - [ ] Add fixtures covering packaging: retained non-semantic JSDoc `@import` comments in emitted declarations, absent private artifacts in the tarball, and a clean package consumer. @@ -424,8 +430,10 @@ type-only and use named `import type { ... }` imports. the repository, under its own `tsconfig.json` with `skipLibCheck: false` — the only arrangement in which a declaration pointing at a deleted `private.d.ts` is an error rather than a silently skipped library file. -- The job is demonstrably falsifiable: removing the `prepack` deletion step, or - reintroducing a private declaration, turns it red. +- Both halves are demonstrably falsifiable, each by the input that actually + breaks it: removing the `prepack` deletion step reddens the contents + assertion, and a packed declaration depending on a private module the + tarball does not carry reddens the consumer type-check. - The CI job is generated from `fjs/ci/**`, so `npm run ci-update` reproduces `.github/workflows/ci.yml` byte-identically. - `fjs/fsc/README.md` no longer needs tolerance for a shipped `private.d.ts`, From e3e2feb2ab27f080afee0a2479c6d37e8be3d1c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:05:58 +0000 Subject: [PATCH 180/370] virtual: say how a fixture spells a real __proto__ entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{ ['__proto__']: e }` — the computed key, which is an own property. The plain and quoted forms set the prototype, which is why FunctionalScript's parser refuses them outright, and why such a fixture never worked here either: `readdir` walks own entries, so the directory listed as empty while `stat` claimed the entry existed. `protoKeyFixture` pins the working spelling through `stat`, `readFile` and `readdir`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BRNUuhnny5cNz1NFa5KoDV --- fjs/effects/node/virtual/module.f.mjs | 10 ++++++++++ fjs/effects/node/virtual/proof.f.mjs | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/fjs/effects/node/virtual/module.f.mjs b/fjs/effects/node/virtual/module.f.mjs index 36f95fb6a..e4226ff8e 100644 --- a/fjs/effects/node/virtual/module.f.mjs +++ b/fjs/effects/node/virtual/module.f.mjs @@ -72,6 +72,16 @@ const { hasOwn } = Object * An own name holding `undefined` is absent too — `Dir`'s values are optional, * and every operation here already reads `undefined` as "no entry". * + * **A fixture spells `__proto__` with a computed key**, `{ ['__proto__']: e }`, + * which is an own property. The plain and quoted forms — `{ __proto__: e }`, + * `{ '__proto__': e }` — set the *prototype* instead, so there is no entry for + * this to find. That is not a rule invented here: FunctionalScript's own parser + * refuses both spellings with `__proto__ requires the computed key form` + * (`../../../djs/parser/`), for this exact reason. The refused spelling was + * never a working fixture anyway — `readdir` walks `Object.entries`, which is + * own-only, so such a directory listed as empty while `stat` claimed the entry + * existed. Now every operation agrees it is absent. + * * @type {(dir: Dir, name: string) => _Entity | undefined} */ const entryOf = (dir, name) => hasOwn(dir, name) ? dir[name] : undefined diff --git a/fjs/effects/node/virtual/proof.f.mjs b/fjs/effects/node/virtual/proof.f.mjs index a35a1e4de..99c4b2280 100644 --- a/fjs/effects/node/virtual/proof.f.mjs +++ b/fjs/effects/node/virtual/proof.f.mjs @@ -515,6 +515,27 @@ export const proof = { assert(again[0] === 'error', again) assertIoCode(again[1], 'EEXIST') }, + // An entry genuinely named `__proto__` is not what the guard refuses, and + // a fixture writes one with a **computed key** — the spelling that makes an + // own property. `{ '__proto__': e }` sets the prototype instead, which is + // why FunctionalScript's own parser refuses that form (`protoKey` in + // `../../../djs/parser/proof.f.mjs`); it was never a working fixture here + // either, since `readdir` walks own entries and would have listed the + // directory as empty. + protoKeyFixture: () => { + /** @type {Dir} */ + const root = { ['__proto__']: [utf8('hi')] } + const [, s] = virtual({ ...emptyState, root })(stat('__proto__')) + assert(s[0] === 'ok', s) + assertEq(s[1].isFile, true) + const [, f] = virtual({ ...emptyState, root })(readFile('__proto__')) + assert(f[0] === 'ok', f) + assertEq(utf8ToString(f[1]), 'hi') + // And the listing agrees, which is the half the refused spelling lost. + const [, d] = virtual({ ...emptyState, root })(readdir('.', {})) + assert(d[0] === 'ok', d) + assertEq(d[1].map(e => e.name).join(), '__proto__') + }, // The writing half, which the reads above do not reach: one proof per // remaining lookup, so a regression confined to a single operation cannot // hide behind the shared helper. From 458170ee7e72950126fee61d17b32d68d1883a2d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:06:48 +0000 Subject: [PATCH 181/370] todo: the package fixture is .f.mjs + types.ts, not a retired .f.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 is pointed at this fixture, and its scope still called for a mixed module.f.ts / module.f.mjs pair — a source form the completed migration retired, so following it literally would reintroduce .f.ts. Retarget it to the current model and name the sibling private.ts the private-declaration check needs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/todo/f-mjs-package-support.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/fjs/ci/todo/f-mjs-package-support.md b/fjs/ci/todo/f-mjs-package-support.md index 23e886d16..b64dac035 100644 --- a/fjs/ci/todo/f-mjs-package-support.md +++ b/fjs/ci/todo/f-mjs-package-support.md @@ -214,9 +214,11 @@ emission, `npm pack`, and a clean consumer. outputs. - [ ] Keep package/publish jobs on a clean CI checkout; do not add generated output tracking or cleanup for artifacts from previous revisions. -- [ ] Add a mixed `module.f.ts` / `module.f.mjs` plus authored `types.ts` package - fixture. Scope: the fixture exercises the supported, fully erased - `import type` form only. The forbidden inline `import { type X }` / +- [ ] Add a package fixture in the current source model — `module.f.mjs` with an + authored `types.ts` and, for the private-declaration check, a sibling + `private.ts` (authored implementation and proof `.f.ts` are retired, so + the fixture must not reintroduce them). Scope: the fixture exercises the + supported, fully erased `import type` form only. The forbidden inline `import { type X }` / `import * as` / side-effect forms are a documented one-time measurement ([`packed-consumer-validation.md`](../packed-consumer-validation.md), "`types.js` is not a real module") — their behavior belongs to consumer From 34b0e2286098b8ad19b016970496e076de0492a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:08:35 +0000 Subject: [PATCH 182/370] edag/todo: commit to arity-split unions; past-the-end rule is rejected Third mechanism-2 gap in a row on #1755, this one with no local answer: a referenced rule like r = or(option, [r]) can sit where its root absence is unobservable and where it is not, so stripping the rule changes the fixpoint, and the data form declines to see through references. The design now decides instead of choosing later: arity-split unions are the mechanism, and the past-the-end rule is recorded as the rejected alternative with the full cascade (readers/printer docs, [option]/[] collapse, interior bits, referenced nodes) as the reason. Tasks lose their per-mechanism branching. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/todo/option-terminated-lambdas.md | 139 ++++++++++----------- 1 file changed, 66 insertions(+), 73 deletions(-) diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md index 68ce306c2..812bec15f 100644 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ b/fjs/edag/todo/option-terminated-lambdas.md @@ -82,12 +82,19 @@ row above a genuine type error: above. `propertyLambda` is not phantom-wrapped, so it needs no wrapper: `_AdmitsAbsence` walks its `or` directly. +Both findings above verify the `option` spelling — the alternative the hole +question below ends up rejecting. They stay as the record of what was +established; the chosen arity-split spelling needs none of that machinery, +its hand-written types being plain unions of exact tuples, the pattern the +schema already uses today. + ### What is gained - `null` in a graph means one thing again: the primitive value. - Every plain property access and every chain end drops one element — fewer elements to store and hash. -- "The chain ends" is spelled as absence, which is what `option` is for. +- "The chain ends" is spelled by the operand not being there — a shorter + closed tuple. ### Trailing holes must be rejected in the same change @@ -102,63 +109,58 @@ the leak is canonicality-only. It is still a validation regression against today's schema, and a regression may not be deferred behind a todo ([`AGENTS.md`](../../../AGENTS.md) §1, "Merge the knowledge"): the migration does not land unless the same change keeps `validate(exp)` rejecting a -trailing hole, pinned in the proofs. Two mechanisms qualify, either is -acceptable: - -1. **Arity-split unions, no `option` at all** (verified against the real - `validate`, no rtti change needed). Each node or step whose continuation - may end is a union of its two closed arities — - `or(['.', exp, index], ['.', exp, index, propertyLambda])`, and likewise - per step — so absence is spelled by the shorter tuple and a hole matches - neither arm: the 3-arity arm rejects length 4, the 4-arity arm has no - `option` and rejects the absent member. `['.', a, 'b', ,]` and - `['|()', c, ,]` both reject; every acceptance row in the table above is - unchanged. Costs: each such kind doubles its union arms, the shared prefix - is written twice, and the `AbsentOr`/`CheckRaw` machinery drops out - (hand-written types are plain unions of exact tuples, no optional - elements). -2. **An rtti rule first: absence in a tuple is the array ending before the - position, never a hole** — the const- and rest-tuple validators' absent - branch additionally requires the index to lie at or past `value.length`. - One condition, and it aligns the readers' array domain with DJS, which has - no sparse arrays — the same direction as - [`data-validate-admits-non-djs-values`](../../rtti/todo/data-validate-admits-non-djs-values.md). - But it reverses documented rtti behavior (`option`'s "position 0 may be a - hole"; `_InteriorTs` rendering interior absence as "what reading a hole - gives") across all three readers and the printer — and it reaches the - data form's canonical algebra: once a hole is no member, `[option]` and - `[]` denote one array set, while `toData` deliberately keeps them - distinct today — `trimPrefix` in - [`../../rtti/data/module.f.mjs`](../../rtti/data/module.f.mjs) exempts - the empty `rest` exactly because the two "differ on `new Array(1)`". - Updating only the validators' absent branches would leave the data form - with two spellings of one set and its `validate`/`equal`/`subset` - disagreeing with the schema readers, so the prerequisite rtti issue must - also specify the `arraySet` normalization that collapses an - absence-admitting trailing position against an empty `rest` (one `Node` - for `[option]` and `[]`), and carry `cmp`/`equal`/`subset`, the data - reader, and the printer with it. Not only trailing, either: under the - rule, a position's absence is realizable exactly when every later - position also admits absence — an array ending before it ends before - them too — so an **interior** absent bit (any position followed by one - whose set excludes absence) becomes unobservable. `[or(option, number), - 3]`, the `option` JSDoc's own example, comes to denote the same set as - `[number, 3]` while `toData` keeps its `absentBit` and `_InteriorTs` - renders a stale `| undefined`; the normalization must strip every - unobservable interior absent bit — the same trailing-run split `TupleTs` - already makes — with the type-level and printer renderings following. - It needs its own rtti issue and lands - **before** this migration, which then keeps the `option` spelling and - machinery described above — and the width of that surface, against - mechanism 1 touching none of it, is itself an argument for mechanism 1. +trailing hole, pinned in the proofs. + +**The chosen mechanism is arity-split unions, no `option` at all** (verified +against the real `validate`, no rtti change needed). Each node or step whose +continuation may end is a union of its two closed arities — +`or(['.', exp, index], ['.', exp, index, propertyLambda])`, and likewise per +step — so absence is spelled by the shorter tuple and a hole matches neither +arm: the 3-arity arm rejects length 4, the 4-arity arm has no `option` and +rejects the absent member. `['.', a, 'b', ,]` and `['|()', c, ,]` both +reject; every acceptance row in the table above is unchanged. Costs: each +such kind doubles its union arms, the shared prefix is written twice, and +the `AbsentOr`/`CheckRaw` machinery drops out — hand-written types are plain +unions of exact tuples, no optional elements. + +**The rejected alternative** — keep the `option` spelling and first land an +rtti rule that absence in a tuple is the array ending before the position, +never a hole (the validators' absent branch requiring the index at or past +`value.length`) — was weighed across three review rounds of +[#1755](https://github.com/functionalscript/functionalscript/pull/1755) and +rejected because its prerequisite grows into an open-ended redesign of +rtti's canonical algebra, not one condition. The rule aligns the readers' +array domain with DJS (no sparse arrays — the direction of +[`data-validate-admits-non-djs-values`](../../rtti/todo/data-validate-admits-non-djs-values.md)), +but it cascades: it reverses documented reader and printer behavior +(`option`'s "position 0 may be a hole"; `_InteriorTs`'s "what reading a hole +gives"); it collapses `[option]` into `[]` — one set once `new Array(1)` is +excluded — while `trimPrefix` in +[`../../rtti/data/module.f.mjs`](../../rtti/data/module.f.mjs) deliberately +keeps their canonical `Node`s distinct, so `arraySet` must renormalize and +`cmp`/`equal`/`subset`, the data reader, and the printer must follow; it +makes every **interior** absent bit unobservable (absence at a position is +realizable only when every later position also admits it, so +`[or(option, number), 3]` comes to denote `[number, 3]` while `toData` keeps +the `absentBit`), so those bits must be stripped, the trailing-run split +`TupleTs` already makes; and even that strip has no local answer for a +**referenced** node — the data form declines to see through a reference +(`trimPrefix` leaves referenced positions alone), and a rule like +`r = or(option, [r])` may sit at one position where its root absence is +unobservable and another where it is not, so stripping the rule itself +changes the fixpoint, and the design would need contextual specialization or +a stated canonicality exception. All of that as a prerequisite for a +migration that does not need it; anyone wanting the past-the-end rule on its +own merits files it as an rtti issue. ## Proposal -Replace the `null` member of all three lambda schemas with `option`, drop the -terminals' third operand, and let the continuation positions of `dot`, -`optionDot`, `optionCall` and the steps end by absence — spelled with -`option` under mechanism 2 above, or as arity-split unions under mechanism 1 -(same accepted values either way). Code changes are small; the bulk is +Drop the `null` member of all three lambda unions and the terminals' third +operand, and let the continuation positions of `dot`, `optionDot`, +`optionCall` and the steps end by the operand's absence — spelled as +arity-split unions, per the chosen mechanism above: each node or step whose +continuation may end becomes a union of its two closed arities, with no +`option` anywhere in the chain schemas. Code changes are small; the bulk is mechanical respelling of proofs and prose. [amnesia](../amnesia/module.f.mjs) barely changes: its four `k === null` @@ -168,25 +170,16 @@ present value there. ### Tasks -The first task decides the shape of the two after it — they are spelled per -mechanism because mixing them reintroduces the hole: an arity-split arm whose -lambda root still carries `option` admits the absent member again. - -- [ ] pick the hole-rejection mechanism: **1** (arity-split unions, no - `option` anywhere in the chain schemas, no rtti change) or **2** (the rtti - past-the-end rule, filed as its own rtti issue and landed first, then the - `option` spelling below) -- [ ] `../module.f.mjs` — under mechanism 2: `option` for `null` in the three - lambda unions; terminals become closed 2-tuples; `AbsentOr` phantom - annotations plus `CheckRaw` asserts for - `_optionLambda`/`_optionPropertyLambda`. Under mechanism 1: every lambda - union and continuation-carrying node splits by arity instead — no `option` - member in any of them, and the phantom annotations stay plain -- [ ] `../types.ts` — under mechanism 2: the optional-element types above; - under mechanism 1: plain unions of exact tuples, one per arity, no +The schemas must not mix the spellings: an arity-split arm whose lambda root +carries `option` admits the absent member — and its hole — again. + +- [ ] `../module.f.mjs`: every lambda union and continuation-carrying node + splits by arity — no `option` member in any of them, terminals are the + shorter arm, phantom annotations stay plain +- [ ] `../types.ts`: plain unions of exact tuples, one per arity, no optional elements -- [ ] `../amnesia/module.f.mjs` (same under either mechanism): - `k === null` → `k === undefined`; signatures take `… | undefined` +- [ ] `../amnesia/module.f.mjs`: `k === null` → `k === undefined`; + signatures take `… | undefined` - [ ] `../proof.f.mjs`, `../amnesia/proof.f.mjs`: respell (~200 trailing `null`s); add rejections for present `null`, present `undefined`, the smuggled continuation on a terminal, and the trailing holes From 27187204405987fa0961eeb87f6c9fb07425910f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:20:11 +0000 Subject: [PATCH 183/370] edag/todo: the hole gate claims own members; hostile prototypes are rtti's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #1755, measured both ways: a prototype-supplied member behind an own trailing hole is read by both spellings alike — index 3 supplying ['|()', c, null] validates under today's schema and rejects under the split one, ['|()', c] exactly the reverse — so neither is hole-proof against a polluted Array.prototype and neither ever was, while a pristine prototype rejects every hole in both. The gate's claim is now stated as about the value's own members under rtti's reading model, with the hostile-host question left where it is tracked (hostile-accessor-hermetic-read-path; the Beyond-length caveat). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/todo/option-terminated-lambdas.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md index 812bec15f..057054221 100644 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ b/fjs/edag/todo/option-terminated-lambdas.md @@ -123,6 +123,25 @@ such kind doubles its union arms, the shared prefix is written twice, and the `AbsentOr`/`CheckRaw` machinery drops out — hand-written types are plain unions of exact tuples, no optional elements. +One boundary of the gate, measured: a **prototype-supplied member behind an +own hole** is read in both spellings alike. The tuple readers decide +presence by HasProperty and read through the prototype — deliberately, so +an inherited index is still held to the schema (`constContainerValidate` in +[`../../rtti/validate/module.f.mjs`](../../rtti/validate/module.f.mjs); +"Beyond `length`" in [`../../rtti/README.md`](../../rtti/README.md)) — so a +polluted `Array.prototype` can back `['.', a, 'b', ,]`'s hole with a +schema-valid continuation under either schema, and only its own vocabulary +decides which: index 3 supplying `['|()', c, null]` validates under +**today's** schema and rejects under the split one, `['|()', c]` exactly +the reverse. Neither spelling is hole-proof against a hostile prototype, +neither ever was, and under a pristine prototype both reject every hole. +The gate's claim is therefore about the value's **own** members under +rtti's stated reading model; what the readers assume of a hostile host is +rtti's question, tracked in +[`hostile-accessor-hermetic-read-path`](../../rtti/todo/hostile-accessor-hermetic-read-path.md) +and the "Beyond `length`" caveat — not something an EDAG-boundary own-index +check should duplicate. + **The rejected alternative** — keep the `option` spelling and first land an rtti rule that absence in a tuple is the array ending before the position, never a hole (the validators' absent branch requiring the index at or past From 6cd4923cdb46ce3b17650782f4d2b39a43669cd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:24:45 +0000 Subject: [PATCH 184/370] todo: exclude private.d.ts from packing, and check it without a checkout Two simplifications measured rather than assumed. A negation in package.json's files ("!**/private.d.ts") drops exactly the 16 emitted private.d.ts from the tarball -- 675 packed files become 659 under npm pack --dry-run -- so Stage 2 needs no prepack deletion step, no script, and no proof for a path predicate. It also leaves the working tree alone, where a deletion would take declarations a following npx tsc expects. The consumer check moves to a job with no repository checkout, fed the tarball as a CI artifact. That is stronger than a directory outside the repository: with no repository on the runner there is no tsconfig.json to inherit, no node_modules to resolve into, and no source file that could stand in for an omitted declaration. Records that skipLibCheck must stay at its false default, that the job should be a required check, and the measurements showing the exclusion is safe today and the check falsifiable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/todo/separate-private-types.md | 165 ++++++++++++++++++----------- 1 file changed, 102 insertions(+), 63 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 6a144f1ea..6310003a7 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -24,8 +24,8 @@ The work lands in two stages that are shippable independently: order, breaking migrations, and the matching policy documentation. 2. **Stage 2 — packaging cleanup.** The [Declaration emission and packaging](#declaration-emission-and-packaging) - rules: delete generated `private.d.ts` as the final `prepack` step and - validate the packed artifact semantically. + rules: exclude generated `private.d.ts` from the package and validate the + packed artifact semantically. Stage 1 is complete on its own. While Stage 2 has not landed, generated `private.d.ts` files ship in the package. That is safe: `types.ts` must not @@ -209,9 +209,23 @@ If `private.ts` is used, keep it in the normal TypeScript program so source user are checked. Declaration emit may therefore create an intermediate `private.d.ts`. -Do not try to exclude `private.ts` from checking. Instead delete generated -`private.d.ts` files as the final `prepack` step, after declaration emit and the -existing declaration round-trip check, before package contents are selected. +Do not try to exclude `private.ts` from *checking*. Exclude the generated +`private.d.ts` from *packing* instead, by a negation in `package.json`'s `files`: + +```json +"files": ["**/*.js", "**/*.d.ts", "**/*.mjs", "**/*.d.mts", "!**/private.d.ts"] +``` + +Measured with `npm pack --dry-run --json`: 675 packed files become 659, and the +16 that disappear are exactly the 16 emitted `private.d.ts`. + +Prefer this to a deletion step in `prepack`. It needs no script, no directory +walk, and no proof for a path predicate; `prepack` keeps doing exactly what it +does now (emit declarations, then re-check with them present); and it leaves the +working tree alone, so a contributor who runs `npm pack` does not silently lose +the declarations a following `npx tsc` expects. It also states the intent where +the rest of the package contents are declared, rather than in a build step that +has to be read to be discovered. Do **not** rewrite/post-process emitted declaration text. TypeScript may retain a source comment such as: @@ -231,38 +245,58 @@ Package validation must check semantic dependencies, not raw text: - no packed declaration semantically depends on an unshipped private type module; - a clean TypeScript consumer installed from the tarball type-checks successfully. -#### The check has to run in CI, on the packed artifact - -Deleting `private.d.ts` is invisible to every check the repository has. `npx tsc` -reads the *source* `private.ts`, so it stays green whatever `prepack` removes; -`node26` runs `npm pack` but nothing installs or type-checks the result, and the -`npm install -g functionalscript@` steps install the *published* CLI, -not the artifact just built. Stage 2 would therefore ship a claim that nothing -could falsify — the same "a sweep, not a check" gap the Stage 1 grep guard -closes. Only a consumer that reads the packed declarations can catch a -declaration left pointing at a file the package no longer carries. - -Three constraints decide whether such a job is real or theatre: - -- **`skipLibCheck` must be off in the consumer.** The repository's - `tsconfig.json` sets `skipLibCheck: true`; inherited, it stops TypeScript from - ever opening the packed `.d.mts` internals, and a dangling private reference - passes silently. The consumer needs its own `tsconfig.json` with - `skipLibCheck: false`. -- **The consumer must import the module surfaces whose declarations referenced - private types**, or the broken declaration is not in its program at all. -- **The consumer directory must be outside the repository**, so - `allowImportingTsExtensions`, `rewriteRelativeImportExtensions`, and the rest - of the repository's compiler options cannot mask a packaging bug. - -A tarball-contents assertion (no `private.d.ts` inside) belongs alongside it, but -it is a cheap complement: the semantic check is the consumer, per the rule above. - -Prefer a separate `package` job over more `node26` steps — it needs that clean -directory, it is independent of `node26`'s other invariants, and a named red -check reports what broke. Either way the job is added through the CI generator -(`fjs/ci/node/module.f.mjs`, composed in `fjs/ci/module.f.mjs`), never by editing -`.github/workflows/ci.yml`, which `npm run ci-update` regenerates. +#### The check has to run in CI, on the packed artifact, without the repository + +Excluding `private.d.ts` from the package is invisible to every check the +repository has. `npx tsc` reads the *source* `private.ts`, so it stays green +whatever the tarball omits; `node26` runs `npm pack` but nothing installs or +type-checks the result, and the `npm install -g functionalscript@` +steps install the *published* CLI, not the artifact just built. Stage 2 would +therefore ship a claim that nothing could falsify — the same "a sweep, not a +check" gap the Stage 1 grep guard closes. Only a consumer that reads the packed +declarations can catch a declaration left pointing at a file the package no +longer carries. + +The shape that makes it a real check: + +1. a job that packs (`npm pack`) and uploads the tarball as a CI artifact; +2. a **second job with no repository checkout** that downloads that artifact, + unpacks it, installs TypeScript, and type-checks a small consumer against + the installed package. + +The missing checkout is the point, and it is stronger than merely working in a +directory outside the repository: with no repository on the runner, there is no +`tsconfig.json` up the tree to inherit, no `node_modules` to resolve into, and +no source file that could stand in for a declaration the tarball omits. The +check can only see what a real consumer sees. + +Two details decide whether that consumer can fail at all: + +- **Do not set `skipLibCheck` in the consumer's `tsconfig.json`.** It defaults + to `false`, which is what makes TypeScript open the packed `.d.mts` internals + and report a dangling private reference. `tsc --init` writes + `"skipLibCheck": true`; if that creeps in, the job silently stops checking the + thing it exists for. +- **The consumer must import the module surfaces whose declarations reference + private types** — today the modules carrying an `@import { _… } from + './private.ts'` comment — or the declaration that could dangle is never in its + program. + +Because a red required check blocks the merge queue, a reintroduced dependency +becomes the author's problem at the moment it is introduced, which is the whole +point of preferring a check to a sweep. + +Measured on the tree at the time of writing: no packed declaration imports a +private module (the `private.ts` mentions that survive emit are JSDoc `@import` +comments, which are inert); deleting all 16 `private.d.ts` and type-checking the +remaining declarations with `skipLibCheck: false` exits 0; and adding one real +`import type { … } from './private.js'` to a packed declaration turns that check +red with `TS2307`. The exclusion is therefore safe today, and the check is +falsifiable. + +The job is added through the CI generator (`fjs/ci/**`, composed in +`fjs/ci/module.f.mjs`), never by editing `.github/workflows/ci.yml`, which +`npm run ci-update` regenerates. This fixture is already scoped in [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md), @@ -356,30 +390,33 @@ type-only and use named `import type { ... }` imports. #### Stage 2 — packaging cleanup -- [ ] If `private.ts` is used, delete generated `private.d.ts` as the final - `prepack` step. +- [ ] Exclude generated `private.d.ts` from the package with a `!**/private.d.ts` + negation in `package.json`'s `files`; leave `prepack` unchanged. - [ ] Do not text-postprocess emitted declarations; validate semantic private dependencies and clean-consumer type checking instead. -- [ ] Add a CI job that validates the packed artifact: install the tarball into - a clean directory outside the repository, with its own `tsconfig.json` - setting `skipLibCheck: false`, and type-check a consumer that imports the - module surfaces whose declarations referenced private types. Add it - through the CI generator (`fjs/ci/node/module.f.mjs`, composed in - `fjs/ci/module.f.mjs`), not by editing `.github/workflows/ci.yml`. Prefer - a separate `package` job over more `node26` steps. Complete the fixture - already scoped in [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) +- [ ] Upload the `npm pack` tarball as a CI artifact, and add a **second job + with no repository checkout** that downloads it, unpacks it, installs + TypeScript, and type-checks a consumer importing the module surfaces whose + declarations reference private types. Leave `skipLibCheck` unset in that + consumer's `tsconfig.json` — it defaults to `false`, which is what makes + the check able to fail. Add both through the CI generator (`fjs/ci/**`, + composed in `fjs/ci/module.f.mjs`), not by editing + `.github/workflows/ci.yml`. Complete the fixture already scoped in + [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) rather than adding a second package-validation path. +- [ ] Make the consumer job a required check, so a reintroduced private + dependency blocks the merge queue rather than landing. - [ ] Assert the tarball's contents (no `private.d.ts` inside) alongside that job — a cheap complement to the semantic consumer check, never its replacement. - [ ] Prove each half can fail, with its own negative control — they fail on - opposite inputs, so one control cannot stand for both. Removing the - `prepack` deletion step leaves `private.d.ts` *in* the tarball, where - every reference to it resolves: that reddens the contents assertion and - leaves the consumer green. The consumer's control is the reverse — a - packed declaration that references a private module the tarball does not - carry (a shipped declaration made to depend on `private.ts`, with the - deletion still running), which resolves in-repo and dangles once packed. + opposite inputs, so one control cannot stand for both. Dropping the + `files` negation leaves `private.d.ts` *in* the tarball, where every + reference to it resolves: that reddens the contents assertion and leaves + the consumer green. The consumer's control is the reverse — a packed + declaration that references a private module the tarball does not carry + (a shipped declaration made to depend on `private.ts`, with the negation + still in place), which resolves in-repo and dangles once packed. - [ ] Add fixtures covering packaging: retained non-semantic JSDoc `@import` comments in emitted declarations, absent private artifacts in the tarball, and a clean package consumer. @@ -420,20 +457,22 @@ type-only and use named `import type { ... }` imports. - The public declaration/API surface is clean: no private type artifact that is intended to be unshipped is present in the tarball. -- If declaration emit creates `private.d.ts`, final-`prepack` cleanup removes it - before packaging. +- Generated `private.d.ts` files are excluded from the package by + `package.json`'s `files`, with `prepack` unchanged. - Emitted declarations are not text-postprocessed; retained JSDoc `@import` comments are allowed when they are non-semantic. - The packed artifact has no semantic dependency on an unshipped private type module, and a clean TypeScript consumer type-checks successfully. -- That consumer runs **in CI**, from the packed tarball, in a directory outside - the repository, under its own `tsconfig.json` with `skipLibCheck: false` — the - only arrangement in which a declaration pointing at a deleted `private.d.ts` - is an error rather than a silently skipped library file. +- That consumer runs **in CI**, from the packed tarball handed over as an + artifact, in a job with **no repository checkout** and with `skipLibCheck` + left at its `false` default — the only arrangement in which a declaration + pointing at an omitted `private.d.ts` is an error rather than a silently + skipped library file or a resolution into the source tree. +- The consumer job is a required check, so the failure blocks the merge queue. - Both halves are demonstrably falsifiable, each by the input that actually - breaks it: removing the `prepack` deletion step reddens the contents - assertion, and a packed declaration depending on a private module the - tarball does not carry reddens the consumer type-check. + breaks it: dropping the `files` negation reddens the contents assertion, and + a packed declaration depending on a private module the tarball does not carry + reddens the consumer type-check. - The CI job is generated from `fjs/ci/**`, so `npm run ci-update` reproduces `.github/workflows/ci.yml` byte-identically. - `fjs/fsc/README.md` no longer needs tolerance for a shipped `private.d.ts`, From 5b354654e9f590d2d69d8b2203569e341b32eb79 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:24:51 +0000 Subject: [PATCH 185/370] edag/todo: cover the past-length prototype index; destructuring never reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #1755, measured: past the end the split 3-arity arm accepts a length-3 dot whatever Array.prototype[3] holds — the unanswered region rtti's Beyond-length caveat states for every bare tuple — while today's schema accepts the same value the moment that index inherits its own null, so the flips are symmetric there too. The claimed executor crash is wrong: amnesia reads nodes by destructuring and the array iterator stops at length, so the inherited index is never read — the fourth slot of a length-3 node is undefined under Array.prototype[3]='junk' while a direct node[3] would read it. The gate paragraph now covers both regions and the amnesia task pins the destructuring read pattern. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/todo/option-terminated-lambdas.md | 54 ++++++++++++++-------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md index 057054221..4f7eab9a0 100644 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ b/fjs/edag/todo/option-terminated-lambdas.md @@ -123,24 +123,37 @@ such kind doubles its union arms, the shared prefix is written twice, and the `AbsentOr`/`CheckRaw` machinery drops out — hand-written types are plain unions of exact tuples, no optional elements. -One boundary of the gate, measured: a **prototype-supplied member behind an -own hole** is read in both spellings alike. The tuple readers decide -presence by HasProperty and read through the prototype — deliberately, so -an inherited index is still held to the schema (`constContainerValidate` in -[`../../rtti/validate/module.f.mjs`](../../rtti/validate/module.f.mjs); -"Beyond `length`" in [`../../rtti/README.md`](../../rtti/README.md)) — so a -polluted `Array.prototype` can back `['.', a, 'b', ,]`'s hole with a -schema-valid continuation under either schema, and only its own vocabulary -decides which: index 3 supplying `['|()', c, null]` validates under -**today's** schema and rejects under the split one, `['|()', c]` exactly -the reverse. Neither spelling is hole-proof against a hostile prototype, -neither ever was, and under a pristine prototype both reject every hole. -The gate's claim is therefore about the value's **own** members under -rtti's stated reading model; what the readers assume of a hostile host is -rtti's question, tracked in -[`hostile-accessor-hermetic-read-path`](../../rtti/todo/hostile-accessor-hermetic-read-path.md) -and the "Beyond `length`" caveat — not something an EDAG-boundary own-index -check should duplicate. +One boundary of the gate, measured both below and past `length`: a +**prototype-supplied index** is rtti's host question, not this migration's, +and the answers are symmetric between the spellings. The tuple readers +decide presence by HasProperty and read a below-`length` index through the +prototype, held to the schema (`constContainerValidate` in +[`../../rtti/validate/module.f.mjs`](../../rtti/validate/module.f.mjs)), +and never answer an index at or past `length` — both stated, with the +`Array.prototype[10] = 99` example, in "Beyond `length`" in +[`../../rtti/README.md`](../../rtti/README.md), as a caveat that "applies +to `array`, `record` and every container schema alike". So a polluted +`Array.prototype` reaches both spellings, and only each one's own +vocabulary decides which values flip: behind `['.', a, 'b', ,]`'s own +length-4 hole, an inherited `['|()', c, null]` validates under **today's** +schema and rejects under the split one, an inherited `['|()', c]` exactly +the reverse; past the end, the split 3-arity arm accepts a length-3 +`['.', a, 'b']` whatever `Array.prototype[3]` holds — the unanswered +region every bare tuple in the repository already has — while today's +schema accepts the same length-3 value the moment `Array.prototype[3]` is +its own `null`. Neither spelling is pollution-proof, neither ever was, and +under a pristine prototype — the only host DJS admits — both reject every +hole and every spelling has exactly one length. The executor is already +safe on the unanswered region: [amnesia](../amnesia/module.f.mjs) reads +nodes by **destructuring**, and the array iterator stops at `length`, so a +prototype-supplied index past the end is never read — measured: with +`Array.prototype[3] = 'junk'`, the destructured fourth slot of a length-3 +node is `undefined` and the chain ends, while a direct `node[3]` would +read `'junk'`. The gate's claim is therefore about the value's **own** +members under rtti's stated reading model; hermetic reads for hostile +hosts are rtti's tracked question +([`hostile-accessor-hermetic-read-path`](../../rtti/todo/hostile-accessor-hermetic-read-path.md)), +not an EDAG-boundary duplicate. **The rejected alternative** — keep the `option` spelling and first land an rtti rule that absence in a tuple is the array ending before the position, @@ -198,7 +211,10 @@ carries `option` admits the absent member — and its hole — again. - [ ] `../types.ts`: plain unions of exact tuples, one per arity, no optional elements - [ ] `../amnesia/module.f.mjs`: `k === null` → `k === undefined`; - signatures take `… | undefined` + signatures take `… | undefined`; keep the destructuring reads — the + iterator stops at `length`, so a prototype-supplied index past a short + node's end is never read (see the gate boundary above) — and never + switch a continuation read to direct indexing - [ ] `../proof.f.mjs`, `../amnesia/proof.f.mjs`: respell (~200 trailing `null`s); add rejections for present `null`, present `undefined`, the smuggled continuation on a terminal, and the trailing holes From c4c5ec9190cbb56339e5166b4370e2d2670e4626 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:29:13 +0000 Subject: [PATCH 186/370] =?UTF-8?q?edag/todo:=20skip=20must=20destructure?= =?UTF-8?q?=20too=20=E2=80=94=20its=20k[2]=20read=20is=20the=20one=20excep?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #1755, confirmed in amnesia/module.f.mjs: the three lambda walkers destructure, but skip reads k[0]/k[1]/k[2] directly — safe today only because every step carries an own third member. After the migration a short step's k[2] would read the prototype, so a polluted Array.prototype[2] could hand skip an inherited continuation the step's own trailing null masks today. The gate paragraph now states the exception and the amnesia task requires rewriting skip to destructure in the same change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/todo/option-terminated-lambdas.md | 32 ++++++++++++++-------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md index 4f7eab9a0..a56fd7d47 100644 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ b/fjs/edag/todo/option-terminated-lambdas.md @@ -143,15 +143,23 @@ region every bare tuple in the repository already has — while today's schema accepts the same length-3 value the moment `Array.prototype[3]` is its own `null`. Neither spelling is pollution-proof, neither ever was, and under a pristine prototype — the only host DJS admits — both reject every -hole and every spelling has exactly one length. The executor is already -safe on the unanswered region: [amnesia](../amnesia/module.f.mjs) reads -nodes by **destructuring**, and the array iterator stops at `length`, so a -prototype-supplied index past the end is never read — measured: with +hole and every spelling has exactly one length. On the executor the +unanswered region is covered by the read pattern: +[amnesia](../amnesia/module.f.mjs)'s three lambda walkers **destructure** +(`const [o, e, cont] = k`), and destructuring goes through the array +iterator, which stops at `length`, so a prototype-supplied index past a +short tuple's end is never read — measured: with `Array.prototype[3] = 'junk'`, the destructured fourth slot of a length-3 node is `undefined` and the chain ends, while a direct `node[3]` would -read `'junk'`. The gate's claim is therefore about the value's **own** -members under rtti's stated reading model; hermetic reads for hostile -hosts are rtti's tracked question +read `'junk'`. The one walker that does **not** is `skip`, which reads +`k[0]`/`k[1]`/`k[2]` directly — safe today only because every step carries +an own third member; after the migration a short step's `k[2]` would read +the prototype, and a polluted `Array.prototype[2]` could hand `skip` an +inherited continuation where the step's own trailing `null` masks that +index today. So `skip` joins the destructuring pattern **in the same +change** — the amnesia task below says so. The gate's claim is therefore +about the value's **own** members under rtti's stated reading model; +hermetic reads for hostile hosts beyond that are rtti's tracked question ([`hostile-accessor-hermetic-read-path`](../../rtti/todo/hostile-accessor-hermetic-read-path.md)), not an EDAG-boundary duplicate. @@ -211,10 +219,12 @@ carries `option` admits the absent member — and its hole — again. - [ ] `../types.ts`: plain unions of exact tuples, one per arity, no optional elements - [ ] `../amnesia/module.f.mjs`: `k === null` → `k === undefined`; - signatures take `… | undefined`; keep the destructuring reads — the - iterator stops at `length`, so a prototype-supplied index past a short - node's end is never read (see the gate boundary above) — and never - switch a continuation read to direct indexing + signatures take `… | undefined`; keep the walkers' destructuring reads — + the iterator stops at `length`, so a prototype-supplied index past a + short node's end is never read (see the gate boundary above) — never + switch a continuation read to direct indexing, and rewrite `skip`'s + direct `k[0]`/`k[1]`/`k[2]` to destructure like the other three walkers, + since a short step's `k[2]` would otherwise read the prototype - [ ] `../proof.f.mjs`, `../amnesia/proof.f.mjs`: respell (~200 trailing `null`s); add rejections for present `null`, present `undefined`, the smuggled continuation on a terminal, and the trailing holes From fb39e6152cab7c383c99eaa636fce0c0e940f257 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:33:11 +0000 Subject: [PATCH 187/370] todo: check every packed declaration, not a fixed consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consumer that imports the surfaces known to carry private types is only as current as its import list. Measured on an installed tarball: with a dangling `./private.js` import injected into `fjs/emergent_testing` — a module with no `private.ts` today, standing in for a future one — a consumer importing all 16 of today's private-carrying surfaces exits 0, while type-checking all 377 packed declarations exits 2 with TS2307. The job would have shipped unable to see the case it exists to catch. So the second job now enumerates the packed `.d.ts` / `.d.mts` from the installed artifact and passes them all to `tsc` as root files. Two further details recorded from doing it: `skipLibCheck` must stay false even with declarations as root files, and the tarball must be installed as a dependency rather than unpacked into `node_modules` by hand, since a later `npm install` prunes it and leaves an empty file list. The type-check's negative control now goes in a module with no `private.ts`, so it proves exhaustiveness too. Also retires the fixture task's last two `.f.ts` requirements. The `.ts` -> `.mjs` runtime direction no longer exists to test: all 225 imports in authored `.ts` are `import type`, and `types.js` is not emitted, so the form is doubly excluded. The reject-runtime-import rule got wider rather than lapsing — the authored `.ts` that remain are exactly the type-level companions — and is restated that way, with the matching acceptance criterion. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/todo/f-mjs-package-support.md | 32 ++++++--- fjs/todo/separate-private-types.md | 97 +++++++++++++++++----------- 2 files changed, 85 insertions(+), 44 deletions(-) diff --git a/fjs/ci/todo/f-mjs-package-support.md b/fjs/ci/todo/f-mjs-package-support.md index b64dac035..789fe5b37 100644 --- a/fjs/ci/todo/f-mjs-package-support.md +++ b/fjs/ci/todo/f-mjs-package-support.md @@ -247,11 +247,25 @@ emission, `npm pack`, and a clean consumer. `types.ts` or `private.ts`, per the file-scope-typedef prohibition) whose name reaches the emitted declarations; tolerate that declaration form without treating it as clean-consumer public API. -- [ ] Test the allowed `.ts` -> `.mjs` runtime dependency direction in a clean - checkout and CI-built package archive. -- [ ] Reject authored `.mjs` runtime imports to remaining relative implementation - `.ts` / `.f.ts`; type-only imports to intentional `types.ts` companions are - allowed. +- [x] Test the allowed `.ts` -> `.mjs` runtime dependency direction in a clean + checkout and CI-built package archive. Retired, not performed: the + direction no longer exists to test. Every authored `.ts` left is a + `types.ts` / `private.ts`, and all 225 of their import statements are + `import type` — measured on the tree after + [#1750](https://github.com/functionalscript/functionalscript/pull/1750). + A runtime dependency out of an authored `.ts` would also need emitted + JavaScript for it, and the decision above settled that `types.js` is not + part of the package layout, so the form is doubly excluded. Writing a + fixture for it would manufacture a source shape the repository forbids. +- [ ] Reject authored `.mjs` runtime imports to any relative authored `.ts` — + the rule outlived the migration and got *wider*, not narrower. It once + guarded against importing implementation `.ts` / `.f.ts`; with those + retired, the remaining authored `.ts` are exactly the type-level + `types.ts` / `private.ts` companions, for which no JavaScript is emitted, + so a runtime import would resolve in the source tree and dangle in the + package. Type-only imports (`import type`, JSDoc `@import`) stay allowed + and are the only permitted form. Currently zero authored `.mjs` violate + this, so the fixture pins a property that already holds. - [x] Type-check and run a clean packed-package consumer under TypeScript, Node, Deno, and Bun using the `types.ts`-backed API. Measured manually in [#1520](https://github.com/functionalscript/functionalscript/pull/1520) @@ -285,9 +299,11 @@ emission, `npm pack`, and a clean consumer. - `_`-prefixed JSDoc typedefs are treated as private API even if declaration emission currently writes them as exported aliases; clean-consumer tests do not depend on those names. -- Remaining implementation `.ts` may import migrated `.mjs`; migrated `.mjs` - cannot runtime-import remaining implementation `.ts` / `.f.ts` or generated - `.js`. +- Authored `.mjs` cannot runtime-import any relative authored `.ts` or generated + `.js`; type-only imports of `types.ts` / `private.ts` companions are the only + permitted form. (The converse allowance — implementation `.ts` importing + migrated `.mjs` — lapsed with the migration: no authored implementation `.ts` + remains to exercise it.) - A clean consumer can import the CI-built `.mjs` runtime and type-check its `types.ts`-backed public API. - `.f.mjs` carries no current-compiler compatibility promise during stage 1. diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 6310003a7..0f82b3cae 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -243,7 +243,8 @@ Package validation must check semantic dependencies, not raw text: - no authored/generated private type artifact that is intended to be unshipped is present in the tarball; - no packed declaration semantically depends on an unshipped private type module; -- a clean TypeScript consumer installed from the tarball type-checks successfully. +- every declaration in the tarball, installed as a clean TypeScript dependency, + type-checks successfully. #### The check has to run in CI, on the packed artifact, without the repository @@ -261,8 +262,8 @@ The shape that makes it a real check: 1. a job that packs (`npm pack`) and uploads the tarball as a CI artifact; 2. a **second job with no repository checkout** that downloads that artifact, - unpacks it, installs TypeScript, and type-checks a small consumer against - the installed package. + installs it (`npm install ./functionalscript-*.tgz typescript`), and + type-checks **every declaration the package ships**. The missing checkout is the point, and it is stronger than merely working in a directory outside the repository: with no repository on the runner, there is no @@ -270,29 +271,44 @@ directory outside the repository: with no repository on the runner, there is no no source file that could stand in for a declaration the tarball omits. The check can only see what a real consumer sees. -Two details decide whether that consumer can fail at all: - -- **Do not set `skipLibCheck` in the consumer's `tsconfig.json`.** It defaults - to `false`, which is what makes TypeScript open the packed `.d.mts` internals - and report a dangling private reference. `tsc --init` writes - `"skipLibCheck": true`; if that creeps in, the job silently stops checking the - thing it exists for. -- **The consumer must import the module surfaces whose declarations reference - private types** — today the modules carrying an `@import { _… } from - './private.ts'` comment — or the declaration that could dangle is never in its - program. +Three details decide whether that job can fail at all: + +- **Check every packed declaration, not a hand-written consumer.** The + temptation is a small `consumer.mts` importing the surfaces known to carry + private types. That check is only as current as its import list: a module + that gains a `private.ts` *later* is not in the program, so its dangling + declaration passes unseen while the job stays green. Enumerate the packed + `.d.ts` / `.d.mts` from the installed package and pass them all to `tsc` as + root files instead — the set is derived from the artifact, so it cannot go + stale. +- **Do not set `skipLibCheck`.** It defaults to `false`, which is what makes + TypeScript open the packed declarations and report a dangling private + reference. `tsc --init` writes `"skipLibCheck": true`; if that creeps in, the + job silently stops checking the thing it exists for. (This matters even with + the declarations as root files: `skipLibCheck` suppresses checking of + declaration files however they entered the program.) +- **Install the tarball as a dependency; do not unpack it into `node_modules` + by hand.** A later `npm install` prunes anything not in `package.json` and + removes it, which turns the whole job into a no-op on an empty file list. Because a red required check blocks the merge queue, a reintroduced dependency becomes the author's problem at the moment it is introduced, which is the whole point of preferring a check to a sweep. -Measured on the tree at the time of writing: no packed declaration imports a -private module (the `private.ts` mentions that survive emit are JSDoc `@import` -comments, which are inert); deleting all 16 `private.d.ts` and type-checking the -remaining declarations with `skipLibCheck: false` exits 0; and adding one real -`import type { … } from './private.js'` to a packed declaration turns that check -red with `TS2307`. The exclusion is therefore safe today, and the check is -falsifiable. +Measured on the tree at the time of writing, with the tarball installed into a +scratch consumer and the 16 `private.d.ts` removed from it: + +- the remaining 377 declarations type-check with `skipLibCheck: false` — exit + `0`, so the exclusion is safe today (the `private.ts` mentions that survive + emit are JSDoc `@import` comments, which are inert); +- appending a real `import type { … } from './private.js'` to one packed + declaration turns that exit `2` with `TS2307`, so the check is falsifiable; +- and the gap the first bullet describes is not hypothetical: with that + injection placed in `fjs/emergent_testing` — a module with no `private.ts` + today, standing in for a future one — a consumer importing all 16 of today's + private-carrying surfaces still exits `0`, while the exhaustive form exits + `2`. A fixed import list would have shipped a check that cannot see the case + it exists to catch. The job is added through the CI generator (`fjs/ci/**`, composed in `fjs/ci/module.f.mjs`), never by editing `.github/workflows/ci.yml`, which @@ -395,28 +411,33 @@ type-only and use named `import type { ... }` imports. - [ ] Do not text-postprocess emitted declarations; validate semantic private dependencies and clean-consumer type checking instead. - [ ] Upload the `npm pack` tarball as a CI artifact, and add a **second job - with no repository checkout** that downloads it, unpacks it, installs - TypeScript, and type-checks a consumer importing the module surfaces whose - declarations reference private types. Leave `skipLibCheck` unset in that - consumer's `tsconfig.json` — it defaults to `false`, which is what makes - the check able to fail. Add both through the CI generator (`fjs/ci/**`, - composed in `fjs/ci/module.f.mjs`), not by editing + with no repository checkout** that downloads it, installs it as a real + dependency (`npm install ./functionalscript-*.tgz typescript` — hand- + unpacking into `node_modules` is pruned by the next `npm install`), and + type-checks **every declaration the package ships**, enumerated from the + installed artifact rather than from a hand-written import list: a module + that gains a `private.ts` later would never enter a fixed consumer's + program. Leave `skipLibCheck` unset — it defaults to `false`, which is + what makes the check able to fail. Add both through the CI generator + (`fjs/ci/**`, composed in `fjs/ci/module.f.mjs`), not by editing `.github/workflows/ci.yml`. Complete the fixture already scoped in [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) rather than adding a second package-validation path. -- [ ] Make the consumer job a required check, so a reintroduced private - dependency blocks the merge queue rather than landing. +- [ ] Make that job a required check, so a reintroduced private dependency + blocks the merge queue rather than landing. - [ ] Assert the tarball's contents (no `private.d.ts` inside) alongside that - job — a cheap complement to the semantic consumer check, never its + job — a cheap complement to the semantic declaration check, never its replacement. - [ ] Prove each half can fail, with its own negative control — they fail on opposite inputs, so one control cannot stand for both. Dropping the `files` negation leaves `private.d.ts` *in* the tarball, where every reference to it resolves: that reddens the contents assertion and leaves - the consumer green. The consumer's control is the reverse — a packed + the type-check green. The type-check's control is the reverse — a packed declaration that references a private module the tarball does not carry (a shipped declaration made to depend on `private.ts`, with the negation - still in place), which resolves in-repo and dangles once packed. + still in place), which resolves in-repo and dangles once packed. Place + that control in a module with **no** `private.ts` today, so it also + proves the check is exhaustive rather than pinned to today's surfaces. - [ ] Add fixtures covering packaging: retained non-semantic JSDoc `@import` comments in emitted declarations, absent private artifacts in the tarball, and a clean package consumer. @@ -462,17 +483,21 @@ type-only and use named `import type { ... }` imports. - Emitted declarations are not text-postprocessed; retained JSDoc `@import` comments are allowed when they are non-semantic. - The packed artifact has no semantic dependency on an unshipped private type - module, and a clean TypeScript consumer type-checks successfully. -- That consumer runs **in CI**, from the packed tarball handed over as an + module, and every declaration it ships type-checks successfully. +- That check runs **in CI**, from the packed tarball handed over as an artifact, in a job with **no repository checkout** and with `skipLibCheck` left at its `false` default — the only arrangement in which a declaration pointing at an omitted `private.d.ts` is an error rather than a silently skipped library file or a resolution into the source tree. -- The consumer job is a required check, so the failure blocks the merge queue. +- Its file set is derived from the installed artifact, so a module that gains a + `private.ts` after the job is written is checked without the job being + edited. +- That job is a required check, so the failure blocks the merge queue. - Both halves are demonstrably falsifiable, each by the input that actually breaks it: dropping the `files` negation reddens the contents assertion, and a packed declaration depending on a private module the tarball does not carry - reddens the consumer type-check. + — placed in a module that has no `private.ts` today — reddens the + declaration type-check. - The CI job is generated from `fjs/ci/**`, so `npm run ci-update` reproduces `.github/workflows/ci.yml` byte-identically. - `fjs/fsc/README.md` no longer needs tolerance for a shipped `private.d.ts`, From cc3e13bc5a4e90f275701d91aa8f1267276796ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:33:48 +0000 Subject: [PATCH 188/370] todo: correct the import-type count after the merge The merge of main added one type-only import to an authored .ts, so the measurement now reads 226. Phrased so the claim is the property, not the number. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/todo/f-mjs-package-support.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fjs/ci/todo/f-mjs-package-support.md b/fjs/ci/todo/f-mjs-package-support.md index 789fe5b37..e93e1e9b1 100644 --- a/fjs/ci/todo/f-mjs-package-support.md +++ b/fjs/ci/todo/f-mjs-package-support.md @@ -250,8 +250,8 @@ emission, `npm pack`, and a clean consumer. - [x] Test the allowed `.ts` -> `.mjs` runtime dependency direction in a clean checkout and CI-built package archive. Retired, not performed: the direction no longer exists to test. Every authored `.ts` left is a - `types.ts` / `private.ts`, and all 225 of their import statements are - `import type` — measured on the tree after + `types.ts` / `private.ts`, and every one of their import statements is + `import type` (226 at the time of writing) — measured on the tree after [#1750](https://github.com/functionalscript/functionalscript/pull/1750). A runtime dependency out of an authored `.ts` would also need emitted JavaScript for it, and the decision above settled that `types.js` is not From f8082809fe2106ab34c8bffaef9011e45d8acced Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:33:55 +0000 Subject: [PATCH 189/370] edag: state the nested constructor shape in the design documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged that this branch declares the nested array/object form final in `fjs/edag/module.f.mjs` while `todo/edag-stage1-discussion.md` still specifies the flat, variadic `["[]", ...node]` / `["{}", ...entries]`, so an implementation following the design document would emit nodes the shipped schema rejects. The finding is right, and the disagreement is older than this branch: `fjs/edag/README.md` writes `['[]', items[]]` and the proofs construct `['[]', [1, 'a', true]]`, so the code and the module documentation already agreed on nesting and only the working design documents were behind. Fixed here are the parts an implementer reads as normative: the structural-operations table, subject 4's resolution and its history paragraph, the object-constructor validation rule, and the three constructor forms in `fjs/djs/todo/compile-modules-to-edag.md`. The reason is stated once under the table — an rtti `Tuple` pins one schema per position, so a tag followed by a homogeneous rest is not spellable inline — with subject 4 carrying a `*Revised:*` note in the form that document already uses for its other revisions. Roughly twenty illustrative examples elsewhere in the discussion still use the flat spelling (`["[]", x, x]`, and `["{}"]` for an empty object, which the schema writes `["{}", []]`). Re-notating those is a separate pass, not least because several sit inside history paragraphs recording what an earlier proposal said — rewriting those would falsify the record rather than correct it. Raised on the review thread rather than done here. Markdown only. The link check reports no broken targets or anchors and main's file-scope-typedef guard passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- fjs/djs/todo/compile-modules-to-edag.md | 6 ++--- todo/edag-stage1-discussion.md | 30 ++++++++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/fjs/djs/todo/compile-modules-to-edag.md b/fjs/djs/todo/compile-modules-to-edag.md index 7a79c49f5..395e819d2 100644 --- a/fjs/djs/todo/compile-modules-to-edag.md +++ b/fjs/djs/todo/compile-modules-to-edag.md @@ -24,7 +24,7 @@ required by EDAG. Object parsing accumulates properties in an `OrderedMap` with `setReplace` and eventually produces a plain `AstObject`; duplicate keys are therefore collapsed and integer-like keys can lose their written order before EDAG conversion. This task must preserve object entries as an ordered sequence in the parser/AST until -they are converted to `['{}', ...entry]`. +they are converted to `['{}', [...entry]]`. ### Proposal @@ -284,10 +284,10 @@ The staged work builds on the basic structural forms already being defined for E - primitive constants directly: `null`, boolean, number, string, `bigint` (`undefined` is `['undefined']`, not a bare constant — see `edag-stage1-discussion.md`'s "Structural operations" table); -- object constructors: `['{}', ...entry]`, where the initial entry form is +- object constructors: `['{}', [...entry]]`, where the initial entry form is `[':', key, value]` and **`key` is a string constant** in this task, matching what the current DJS parser produces; -- array constructors: `['[]', ...node]`; +- array constructors: `['[]', [...node]]`; - the argument array: `['args']`; - Stage 1 property access: `['.', object, property, null]`, with the restricted property operands described above — the `null` is the continuation operand, saying diff --git a/todo/edag-stage1-discussion.md b/todo/edag-stage1-discussion.md index 785cf0d28..35baaa68d 100644 --- a/todo/edag-stage1-discussion.md +++ b/todo/edag-stage1-discussion.md @@ -217,8 +217,8 @@ schema is free to change independently of both. |----|--|-----|-----| |`2.5`, `"a"`, `true`, `null`, `34n`|itself|1|constant — any non-object, non-array value| |`["undefined"]`|`undefined`|1|the value `undefined`, as its own node — a bare `undefined` would be indistinguishable from a missing tuple position (a position past a node's arity reads as `undefined` too), so it is not a bare constant like the row above| -|`["[]", ...node]`|`[…]`|1|array constructor| -|`["{}", ...entry]`|`{ … }`|1|ordered object constructor; initial entry form is `[":", key, value]` (subject 4)| +|`["[]", [...node]]`|`[…]`|1|array constructor; the elements are one operand, an array of nodes — not spread across the tuple| +|`["{}", [...entry]]`|`{ … }`|1|ordered object constructor; the entries are one operand, an array — initial entry form is `[":", key, value]` (subject 4)| |`["args"]`|—|1|the arguments array (subject 2)| |`[".", object, property, k]`|`o.p`, `o[p]`, `o.p(...args)`|1|property access, owning whatever its receiver is used for; `k` is `null` for a plain read; `property` is restricted (see below)| |`["()", callee, args]`|`f(...args)`|2|call with no receiver; `args` is one node yielding an array (subject 6)| @@ -232,8 +232,18 @@ schema is free to change independently of both. |`[",", ...node, node]`|`(a, b)`|later|membership without order (subject 8)| |`["=>", frame, body]`|`(…) => …`|2|function; `frame` is a general `exp` in the schema — Stage 2's own compiler/interpreter scope is narrower and only emits/accepts a placeholder for it, captured frames come later| -`["{}", ...entry]` is an ordered object-construction operation. Stage 1 -uses `[":", key, value]` entries. The entry list preserves the source +`["{}", [...entry]]` is an ordered object-construction operation. Stage 1 +uses `[":", key, value]` entries. + +Both structural constructors take their variadic part as **one operand +holding an array**, rather than spreading it across the tuple. An rtti +`Tuple` pins one schema per position, so "this literal tag, then any number +of further positions, all matching this one schema" is not spellable inline; +growing the `Type` ADT to allow it has no other consumer here. The nesting is +the decided representation rather than a stand-in for a flat one — see +`array` in [`../fjs/edag/module.f.mjs`](../fjs/edag/module.f.mjs), and +[`../fjs/edag/README.md`](../fjs/edag/README.md), whose form column writes +the same shape as `['[]', items[]]`. The entry list preserves the source property sequence. The key position is a node, and validation admits any node there — a computed key like `{ ["sss" + 3]: x }` is valid JS and a validly-shaped EDAG, even though today's compiler only lowers the trivial @@ -763,7 +773,7 @@ open: **Status:** decided (revised) -**Resolution: an object constructor is `["{}", ...entries]`, and the +**Resolution: an object constructor is `["{}", [...entries]]`, and the entry sequence is semantic.** Stage 1 uses one entry form, `[":", key, value]`. Both the key and value positions are ordinary EDAG nodes — `{ ["sss" + 3]: x }` is valid JS, the key is a computed expression @@ -773,6 +783,10 @@ compiler would never emit that" is not an admissible reason to narrow what validation accepts (subject 1). Entry forms belong to the object constructor rather than to the general expression vocabulary. +*Revised: the entries are one operand, an array, not spread across the +tuple* — the schema cannot express the flat spelling and the nested form is +what shipped; see the note under the structural-operations table. + *Revised: validation does not restrict the key to a string constant.* An earlier draft of this resolution stated "current validation nevertheless accepts only string-constant keys" — dropped for the same reason subject 1 @@ -787,7 +801,7 @@ perfectly well-formed EDAGs. History: this subject previously represented an object constructor as a plain EDAG object and rejected duplicate keys during validation. The revised -representation uses the tagged `["{}", ...entries]` operation, reserves plain +representation uses the tagged `["{}", [...entries]]` operation, reserves plain objects for future use, and keeps duplicate entries so construction can follow JavaScript overwrite semantics and later support computed keys. @@ -883,8 +897,8 @@ the FJS compiler would never emit. To validate: from another operand of the same `","` is redundant (well-formedness, subject 8); - unknown command tags: validation error; -- object constructors: every `["{}", ...]` operand must be a recognized - entry form; `[":", key, value]` admits any node in `key`, not just a +- object constructors: every element of a `["{}"]` node's entry array must be + a recognized entry form; `[":", key, value]` admits any node in `key`, not just a string constant (subject 4). Duplicate property keys/entries are valid and are applied in order (subject 4). Entry descriptor containers are structural and never independently evaluated, so their identity is not checked — From 38923240a38813cca0f10e7c379a1cca64e9f15c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:34:33 +0000 Subject: [PATCH 190/370] edag/todo: executor host-hardening ends at the uniform read pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenth review round on #1755 flips the eighth's remedy: destructuring dispatches an own overridden Symbol.iterator, so it now asks for length-checked indexed reads instead. The doc closes the family: under a hostile host every read style has its own attack, the three lambda walkers destructure today so an iterator-hostile step already misleads the current evaluator identically, and amnesia's README scopes it as deliberately not a VM — its guarantees assume a DJS value on a pristine host, where the styles coincide. skip joins the one uniform pattern; hostile-host executor hardening is out of amnesia's charter. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/todo/option-terminated-lambdas.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md index a56fd7d47..9ce3ea123 100644 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ b/fjs/edag/todo/option-terminated-lambdas.md @@ -157,11 +157,24 @@ an own third member; after the migration a short step's `k[2]` would read the prototype, and a polluted `Array.prototype[2]` could hand `skip` an inherited continuation where the step's own trailing `null` masks that index today. So `skip` joins the destructuring pattern **in the same -change** — the amnesia task below says so. The gate's claim is therefore -about the value's **own** members under rtti's stated reading model; -hermetic reads for hostile hosts beyond that are rtti's tracked question +change** — the amnesia task below says so. That is where executor-side +host-hardening **ends** for this migration, deliberately. Under a hostile +host every read style has its own attack — a direct index without a length +check reads the prototype, destructuring dispatches an own overridden +`Symbol.iterator`, which can fabricate elements — and the three lambda +walkers destructure **today**, so an iterator-hostile step already +misleads the current evaluator identically; the migration changes nothing +about that class, and `skip` joining the module's one uniform pattern adds +no sensitivity the module does not already have. Amnesia's own README +scopes it: a tree-walking evaluator for testing the semantics, +"deliberately **not** a VM to run FunctionalScript on" — its guarantees +assume a DJS value on a pristine host, where every read style coincides. +The gate's claim is therefore about the value's **own** members under +rtti's stated reading model; hermetic reads for hostile hosts beyond that +are rtti's tracked question ([`hostile-accessor-hermetic-read-path`](../../rtti/todo/hostile-accessor-hermetic-read-path.md)), -not an EDAG-boundary duplicate. +and hardening an executor against a hostile host is a VM concern, out of +scope for amnesia by its own charter — not an EDAG-boundary duplicate. **The rejected alternative** — keep the `option` spelling and first land an rtti rule that absence in a tuple is the array ending before the position, From d537c72cb8de922a1a2bc5bd405fb3710ec0af15 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:34:35 +0000 Subject: [PATCH 191/370] todo: the packed-artifact job needs an ordering edge on the pack job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without `needs`, GitHub Actions starts the two jobs in parallel and `download-artifact` fails before the check runs — a red required check for the wrong reason. It is a prerequisite rather than a detail: `jobSchema` in `fjs/ci/common/module.f.mjs` is closed and names only `runs-on` and `steps`, and it is the same schema `parseGitHubAction` reads the generated workflow back through in `fjs/ci/proof.f.mjs`, so emitting a bare `needs:` key would fail that round-trip. Records extending the schema, `Job`, and the proof as part of Stage 2, with the matching acceptance criterion. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/todo/separate-private-types.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 0f82b3cae..b8eb5f342 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -261,9 +261,10 @@ longer carries. The shape that makes it a real check: 1. a job that packs (`npm pack`) and uploads the tarball as a CI artifact; -2. a **second job with no repository checkout** that downloads that artifact, - installs it (`npm install ./functionalscript-*.tgz typescript`), and - type-checks **every declaration the package ships**. +2. a **second job with no repository checkout**, ordered after the first by an + explicit `needs`, that downloads that artifact, installs it + (`npm install ./functionalscript-*.tgz typescript`), and type-checks + **every declaration the package ships**. The missing checkout is the point, and it is stronger than merely working in a directory outside the repository: with no repository on the runner, there is no @@ -423,6 +424,17 @@ type-only and use named `import type { ... }` imports. `.github/workflows/ci.yml`. Complete the fixture already scoped in [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) rather than adding a second package-validation path. +- [ ] Give the second job an explicit `needs` edge on the pack job. Without it + GitHub Actions starts the two in parallel and `download-artifact` fails + before the check has run — a red required check for the wrong reason. + This is a prerequisite, not a detail: `jobSchema` in + `fjs/ci/common/module.f.mjs` is **closed** and names only `runs-on` and + `steps`, and it is the same schema `parseGitHubAction` reads the + generated workflow back through, so emitting a bare `needs:` key would + fail that round-trip in `fjs/ci/proof.f.mjs`. Extend the schema + (`needs: or(option, array(string))`, matching the existing optional-field + idiom in `stepSchema`), which widens `Job` in `fjs/ci/common/types.ts`, + and cover the new field in the proof. - [ ] Make that job a required check, so a reintroduced private dependency blocks the merge queue rather than landing. - [ ] Assert the tarball's contents (no `private.d.ts` inside) alongside that @@ -492,6 +504,9 @@ type-only and use named `import type { ... }` imports. - Its file set is derived from the installed artifact, so a module that gains a `private.ts` after the job is written is checked without the job being edited. +- That job `needs` the pack job, so it never races the upload, and the closed + `jobSchema` / `Job` / proof in `fjs/ci/common` are extended to express it + rather than the key being emitted past the schema. - That job is a required check, so the failure blocks the merge queue. - Both halves are demonstrably falsifiable, each by the input that actually breaks it: dropping the `files` negation reddens the contents assertion, and From 7313688ad914492db1007bb0e821e694852f60e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:39:26 +0000 Subject: [PATCH 192/370] todo: pin the packed-artifact job's compiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no checkout there is no lockfile, so a bare `npm install typescript` resolves whatever the registry publishes that day — the required check could redden, or quietly change module-resolution behaviour, with no change to this repository. That is the same wrong-reason failure the `needs` edge avoids. The version is readable without a checkout: `npm pack` keeps `devDependencies` in the packed `package.json`, verified on the tarball built here, which carries `"typescript": "=7.0.2"` — the repository's exact pin and the version the measurements in this document were taken with. Records the trade too: determinism over consumer-compiler coverage, with a second pinned version as the way to widen it rather than letting the first float. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/todo/separate-private-types.md | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index b8eb5f342..2da0a2d06 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -262,9 +262,9 @@ The shape that makes it a real check: 1. a job that packs (`npm pack`) and uploads the tarball as a CI artifact; 2. a **second job with no repository checkout**, ordered after the first by an - explicit `needs`, that downloads that artifact, installs it - (`npm install ./functionalscript-*.tgz typescript`), and type-checks - **every declaration the package ships**. + explicit `needs`, that downloads that artifact, installs it — the tarball + plus the exact `typescript` version read from the tarball's own + `package.json` — and type-checks **every declaration the package ships**. The missing checkout is the point, and it is stronger than merely working in a directory outside the repository: with no repository on the runner, there is no @@ -291,6 +291,18 @@ Three details decide whether that job can fail at all: - **Install the tarball as a dependency; do not unpack it into `node_modules` by hand.** A later `npm install` prunes anything not in `package.json` and removes it, which turns the whole job into a no-op on an empty file list. +- **Pin the compiler.** With no checkout there is no lockfile, so a bare + `npm install typescript` resolves whatever the registry publishes that day: + the required check could turn red — or quietly change module-resolution + behaviour — with no change to this repository, the same "red for the wrong + reason" failure that trains people to re-run a check instead of reading it. + Install the repository's exact version, which the job can read without a + checkout because `npm pack` keeps `devDependencies` in the packed + `package.json`: `node_modules/functionalscript/package.json` carries + `"typescript": "=7.0.2"` in the tarball measured here. That deliberately + trades consumer-compiler *coverage* for determinism — if a future compiler + is worth checking against, add it as a second explicitly pinned version + rather than letting the first one float. Because a red required check blocks the merge queue, a reintroduced dependency becomes the author's problem at the moment it is introduced, which is the whole @@ -413,8 +425,11 @@ type-only and use named `import type { ... }` imports. dependencies and clean-consumer type checking instead. - [ ] Upload the `npm pack` tarball as a CI artifact, and add a **second job with no repository checkout** that downloads it, installs it as a real - dependency (`npm install ./functionalscript-*.tgz typescript` — hand- - unpacking into `node_modules` is pruned by the next `npm install`), and + dependency — the tarball plus the exact `typescript` version read from + the tarball's own `package.json`, never a floating install, which would + let the registry redden a required check with no repository change; + hand-unpacking into `node_modules` is pruned by the next `npm install` — + and type-checks **every declaration the package ships**, enumerated from the installed artifact rather than from a hand-written import list: a module that gains a `private.ts` later would never enter a fixed consumer's @@ -503,7 +518,9 @@ type-only and use named `import type { ... }` imports. skipped library file or a resolution into the source tree. - Its file set is derived from the installed artifact, so a module that gains a `private.ts` after the job is written is checked without the job being - edited. + edited, and its compiler is the repository's exact pinned `typescript`, read + from the packed `package.json`, so the check cannot change verdict without a + change to this repository. - That job `needs` the pack job, so it never races the upload, and the closed `jobSchema` / `Job` / proof in `fjs/ci/common` are extended to express it rather than the key being emitted past the schema. From a767b30c629fa3e255c6169c7dd63c951dfad828 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:40:35 +0000 Subject: [PATCH 193/370] edag: move the nested-operand rationale from JSDoc to the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's second pass caught that the JSDoc added in 563ac09 now describes a disagreement that no longer exists: it contrasted the nested schema against a design document that writes the constructors flat, and f808280 changed that document to write them nested. A reader would go looking for a conflict that had just been resolved. fjs/AGENTS.md §2's documentation-home table settles where the text belongs rather than how to reword it — API shape and invariants in JSDoc, "architectural choices, why this / why not that" in the README — and the rationale is the second kind. `fjs/edag/module.f.mjs` is therefore restored to exactly its state on main; the shape was already noted there by the pre-existing `// Object — same nesting as `array` above` marker, so the JSDoc addition was redundant even before it went stale. The reason now sits in `fjs/edag/README.md`, extending the paragraph that already explains the `[]` suffix — the nearest thing to a home it had. That still satisfies what retiring `prefix-then-rest-tuple` required: the decision is written down where a reader of this module will meet it, and the design document points at it instead of restating it. `npx tsc` is clean, the suite passes 3521/3521, main's file-scope-typedef guard passes, and the link check reports no broken targets or anchors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- fjs/edag/README.md | 11 +++++++++++ fjs/edag/module.f.mjs | 11 ----------- todo/edag-stage1-discussion.md | 12 ++++-------- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/fjs/edag/README.md b/fjs/edag/README.md index bfa70dafc..15c2b48a6 100644 --- a/fjs/edag/README.md +++ b/fjs/edag/README.md @@ -82,6 +82,17 @@ named schema, not one of it: `['[]', items[]]` holds a whole array of prose and load-bearing in the schema — a single element where the array belongs still validates plenty of values, just the wrong ones. +**Why an array operand rather than a variadic tail.** `['[]', [a, b]]` +rather than `['[]', a, b]`, and the same one position further in for +`['{}', …]`. An rtti `Tuple` pins one schema per position, so "this literal +tag, then any number of further positions, all matching this one schema" is +not spellable inline in a bigger `Const` tuple; `array`/`record` say exactly +that, but only as their own single operand. Growing the `Type` ADT to admit +a fixed prefix followed by a homogeneous rest has no other consumer here, so +the array operand is the decided representation rather than a stand-in for a +flat one — [`todo/edag-stage1-discussion.md`](../../todo/edag-stage1-discussion.md) +writes the same shape. + A continuation is **not** an array. It is `null` or one step holding the next continuation, so a chain is a linked list whose link type changes as it goes — which link type is legal where is the whole of [Chains](#chains) below. diff --git a/fjs/edag/module.f.mjs b/fjs/edag/module.f.mjs index 746359db1..114378d95 100644 --- a/fjs/edag/module.f.mjs +++ b/fjs/edag/module.f.mjs @@ -110,14 +110,6 @@ export const items = or(exp, spread) * [exp0, exp1] * [exp0, ...exp1] * ``` - * - * The variadic part is nested one position deep — `['[]', [elem, elem, …]]` - * — where the spec's structural-operations table writes it flat, - * `['[]', ...elements]`. That is the decided representation, not a - * workaround: an rtti `Tuple` pins one schema per position, so a fixed - * prefix followed by a homogeneous rest cannot be spread inline into a - * bigger `Const` tuple, and growing the `Type` ADT to spell it flat has no - * other consumer in this repository. `object` nests for the same reason. */ export const array = /** @type {const} */ (['[]', rttiArray(items)]) @@ -166,9 +158,6 @@ export const properties = or(property, spread) * spellings assign a prototype instead and lose the property. See "the * `__proto__` key" in `../../spec/README.md`. */ -// The entries nest one position deep, `['{}', [entry, entry, …]]`, rather -// than the spec's flat `['{}', ...entries]` — see `array` above for why that -// is the decided representation. export const object = /** @type {const} */ (['{}', rttiArray(properties)]) // Number diff --git a/todo/edag-stage1-discussion.md b/todo/edag-stage1-discussion.md index 35baaa68d..cc2ef7604 100644 --- a/todo/edag-stage1-discussion.md +++ b/todo/edag-stage1-discussion.md @@ -236,14 +236,10 @@ schema is free to change independently of both. uses `[":", key, value]` entries. Both structural constructors take their variadic part as **one operand -holding an array**, rather than spreading it across the tuple. An rtti -`Tuple` pins one schema per position, so "this literal tag, then any number -of further positions, all matching this one schema" is not spellable inline; -growing the `Type` ADT to allow it has no other consumer here. The nesting is -the decided representation rather than a stand-in for a flat one — see -`array` in [`../fjs/edag/module.f.mjs`](../fjs/edag/module.f.mjs), and -[`../fjs/edag/README.md`](../fjs/edag/README.md), whose form column writes -the same shape as `['[]', items[]]`. The entry list preserves the source +holding an array**, rather than spreading it across the tuple — the shape +[`../fjs/edag/README.md`](../fjs/edag/README.md) writes as `['[]', items[]]`, +and where "Why an array operand rather than a variadic tail" gives the +reason. The entry list preserves the source property sequence. The key position is a node, and validation admits any node there — a computed key like `{ ["sss" + 3]: x }` is valid JS and a validly-shaped EDAG, even though today's compiler only lowers the trivial From 8b80a9166d1eb7a83ab39eb0328fc8761a9cc848 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 16:46:12 +0000 Subject: [PATCH 194/370] todo: give each Stage 2 task one owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit todo/README.md says a higher-level todo/ must not duplicate tasks belonging in a child todo/, and this had started to: fjs/ci/todo/ci-integration-tests.md already owns "run npm pack, upload as a GitHub Actions artifact" and "download artifact, install", and f-mjs-package-support.md already owns the clean packed-package consumer. Two owners for one task is how contradictory requirements appear when only one of them is updated. Split along ownership rather than by deleting: - ci-integration-tests.md takes the job-ordering edge and the jobSchema / Job / proof extension it needs. That issue's own two-stage split needs the same edge, so owning it there stops whichever consumer lands first from implementing another issue's task. - f-mjs-package-support.md takes the checkout-less type-check job and the four details that decide whether it can fail — exhaustive file set, skipLibCheck false, install as a dependency, pinned compiler — each with its reasoning. - separate-private-types.md keeps what is actually its own: the requirement, the conditions it needs that job to satisfy, and the measurements that justify them. Nothing was dropped; the conditions are stated as conditions instead of as generator tasks. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/todo/ci-integration-tests.md | 15 +++++ fjs/ci/todo/f-mjs-package-support.md | 27 ++++++++ fjs/todo/separate-private-types.md | 96 ++++++++++------------------ 3 files changed, 75 insertions(+), 63 deletions(-) diff --git a/fjs/ci/todo/ci-integration-tests.md b/fjs/ci/todo/ci-integration-tests.md index 44b38280e..9108fea91 100644 --- a/fjs/ci/todo/ci-integration-tests.md +++ b/fjs/ci/todo/ci-integration-tests.md @@ -25,6 +25,21 @@ Open questions: - [ ] Define the scenario interface (`export const main: NodeProgram` or similar). - [ ] Implement the artifact publish step in the CI generator (run `npm pack`, upload as a GitHub Actions artifact). +- [ ] Teach the CI generator to express job ordering, so a consuming job cannot + start before the artifact is uploaded. `jobSchema` in + `fjs/ci/common/module.f.mjs` is deliberately **closed** and names only + `runs-on` and `steps`, and it is the same schema `parseGitHubAction` + reads the generated workflow back through (`fjs/ci/proof.f.mjs`), so a + bare `needs:` key would fail that round-trip rather than merely being + unmodelled. Add `needs: or(option, array(string))` — the optional-field + idiom already used in `stepSchema` — which widens `Job` in + `fjs/ci/common/types.ts`, and cover the new field in the proof. Without + it the two stages race and the consumer fails at `download-artifact`: + red for the wrong reason, which is the one failure mode that trains + people to re-run a check instead of reading it. This blocks the stage + split below and the packed-declaration check in + [`../../todo/separate-private-types.md`](../../todo/separate-private-types.md) + alike, so it is owned here rather than by either consumer. - [ ] Implement scenario job generation: download artifact, install, run `main`. - [ ] Port existing demo/smoke-test steps (`fjs t`, `deno run … t`, `bunx … t`) to the scenario model. - [ ] Document the scenario authoring convention. diff --git a/fjs/ci/todo/f-mjs-package-support.md b/fjs/ci/todo/f-mjs-package-support.md index e93e1e9b1..7d7929066 100644 --- a/fjs/ci/todo/f-mjs-package-support.md +++ b/fjs/ci/todo/f-mjs-package-support.md @@ -274,6 +274,33 @@ emission, `npm pack`, and a clean consumer. CI fixture is the remaining fixture work above. - [ ] Verify the CI-built archive contains exactly the generated/runtime/type artifacts needed for the `types.ts` convention during stage 1. +- [ ] Run the clean packed-package consumer **in CI**, in a job with no + repository checkout, consuming the tarball handed over as an artifact by + [`ci-integration-tests.md`](ci-integration-tests.md) (which also owns the + job-ordering edge that keeps it from racing the upload). The missing + checkout is the point and is stronger than merely working outside the + repository: with no repository on the runner there is no `tsconfig.json` + up the tree to inherit, no `node_modules` to resolve into, and no source + file that could stand in for a declaration the tarball omits. Four + details decide whether such a job can fail at all, each learned by + measurement rather than reasoning: + - **Type-check every packed declaration**, enumerated from the installed + artifact — not a hand-written consumer importing today's known + surfaces, whose import list goes stale the moment a module changes. + - **Leave `skipLibCheck` at its `false` default.** `tsc --init` writes + `true`; that silently turns the job into a no-op. It applies to + declaration files however they enter the program, root files included. + - **Install the tarball as a real dependency**, never by unpacking into + `node_modules` by hand — a later `npm install` prunes what is not in + `package.json`, leaving the check passing on an empty file list. + - **Pin the compiler** to the repository's exact `typescript` version. + With no checkout there is no lockfile, so a bare `npm install + typescript` lets the registry change the verdict with no repository + change. The version is readable without a checkout: `npm pack` keeps + `devDependencies` in the packed `package.json`. + The private-declaration assertion this job carries for + [`../../todo/separate-private-types.md`](../../todo/separate-private-types.md) + is a condition on it, specified there; the job itself belongs here. - [x] Update `AGENTS.md` to the asymmetric `.f.ts` / `.f.mjs` migration policy. - [x] Decide, based on the fixture, whether the second TypeScript runtime-emission pass can ever be removed while authored `types.ts` files remain, or whether diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 2da0a2d06..70894c682 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -272,37 +272,14 @@ directory outside the repository: with no repository on the runner, there is no no source file that could stand in for a declaration the tarball omits. The check can only see what a real consumer sees. -Three details decide whether that job can fail at all: - -- **Check every packed declaration, not a hand-written consumer.** The - temptation is a small `consumer.mts` importing the surfaces known to carry - private types. That check is only as current as its import list: a module - that gains a `private.ts` *later* is not in the program, so its dangling - declaration passes unseen while the job stays green. Enumerate the packed - `.d.ts` / `.d.mts` from the installed package and pass them all to `tsc` as - root files instead — the set is derived from the artifact, so it cannot go - stale. -- **Do not set `skipLibCheck`.** It defaults to `false`, which is what makes - TypeScript open the packed declarations and report a dangling private - reference. `tsc --init` writes `"skipLibCheck": true`; if that creeps in, the - job silently stops checking the thing it exists for. (This matters even with - the declarations as root files: `skipLibCheck` suppresses checking of - declaration files however they entered the program.) -- **Install the tarball as a dependency; do not unpack it into `node_modules` - by hand.** A later `npm install` prunes anything not in `package.json` and - removes it, which turns the whole job into a no-op on an empty file list. -- **Pin the compiler.** With no checkout there is no lockfile, so a bare - `npm install typescript` resolves whatever the registry publishes that day: - the required check could turn red — or quietly change module-resolution - behaviour — with no change to this repository, the same "red for the wrong - reason" failure that trains people to re-run a check instead of reading it. - Install the repository's exact version, which the job can read without a - checkout because `npm pack` keeps `devDependencies` in the packed - `package.json`: `node_modules/functionalscript/package.json` carries - `"typescript": "=7.0.2"` in the tarball measured here. That deliberately - trades consumer-compiler *coverage* for determinism — if a future compiler - is worth checking against, add it as a second explicitly pinned version - rather than letting the first one float. +Four details decide whether that job can fail at all — enumerate every packed +declaration rather than trusting a hand-written import list; leave +`skipLibCheck` at its `false` default; install the tarball as a real dependency; +and pin the compiler. They are conditions on a job this design does not own, so +they are recorded as tasks in +[`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) +with the reasoning for each. The first is the one this design turns on, and the +measurement below is why. Because a red required check blocks the merge queue, a reintroduced dependency becomes the author's problem at the moment it is introduced, which is the whole @@ -316,7 +293,7 @@ scratch consumer and the 16 `private.d.ts` removed from it: emit are JSDoc `@import` comments, which are inert); - appending a real `import type { … } from './private.js'` to one packed declaration turns that exit `2` with `TS2307`, so the check is falsifiable; -- and the gap the first bullet describes is not hypothetical: with that +- and the gap the first of those describes is not hypothetical: with that injection placed in `fjs/emergent_testing` — a module with no `private.ts` today, standing in for a future one — a consumer importing all 16 of today's private-carrying surfaces still exits `0`, while the exhaustive form exits @@ -423,33 +400,22 @@ type-only and use named `import type { ... }` imports. negation in `package.json`'s `files`; leave `prepack` unchanged. - [ ] Do not text-postprocess emitted declarations; validate semantic private dependencies and clean-consumer type checking instead. -- [ ] Upload the `npm pack` tarball as a CI artifact, and add a **second job - with no repository checkout** that downloads it, installs it as a real - dependency — the tarball plus the exact `typescript` version read from - the tarball's own `package.json`, never a floating install, which would - let the registry redden a required check with no repository change; - hand-unpacking into `node_modules` is pruned by the next `npm install` — - and - type-checks **every declaration the package ships**, enumerated from the - installed artifact rather than from a hand-written import list: a module - that gains a `private.ts` later would never enter a fixed consumer's - program. Leave `skipLibCheck` unset — it defaults to `false`, which is - what makes the check able to fail. Add both through the CI generator - (`fjs/ci/**`, composed in `fjs/ci/module.f.mjs`), not by editing - `.github/workflows/ci.yml`. Complete the fixture already scoped in - [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) - rather than adding a second package-validation path. -- [ ] Give the second job an explicit `needs` edge on the pack job. Without it - GitHub Actions starts the two in parallel and `download-artifact` fails - before the check has run — a red required check for the wrong reason. - This is a prerequisite, not a detail: `jobSchema` in - `fjs/ci/common/module.f.mjs` is **closed** and names only `runs-on` and - `steps`, and it is the same schema `parseGitHubAction` reads the - generated workflow back through, so emitting a bare `needs:` key would - fail that round-trip in `fjs/ci/proof.f.mjs`. Extend the schema - (`needs: or(option, array(string))`, matching the existing optional-field - idiom in `stepSchema`), which widens `Job` in `fjs/ci/common/types.ts`, - and cover the new field in the proof. +- [ ] Depend on the checkout-less packed-artifact type-check job rather than + specifying it here: the job belongs to + [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md), + and the artifact hand-off and job-ordering edge it rests on belong to + [`../ci/todo/ci-integration-tests.md`](../ci/todo/ci-integration-tests.md). + What this design requires *of* that job, and what it must not lose: + - it type-checks **every** packed declaration, enumerated from the + installed artifact — a fixed import list cannot see a module that gains + a `private.ts` after the job is written, which is exactly the case this + stage exists to catch; + - it runs with no repository checkout, so nothing in the source tree can + stand in for an omitted `private.d.ts`; + - `skipLibCheck` stays `false`, or the check silently stops checking. + Adding a second package-validation path instead of completing that + fixture would put the private-types assertion somewhere the packaging + work does not own. - [ ] Make that job a required check, so a reintroduced private dependency blocks the merge queue rather than landing. - [ ] Assert the tarball's contents (no `private.d.ts` inside) alongside that @@ -521,9 +487,9 @@ type-only and use named `import type { ... }` imports. edited, and its compiler is the repository's exact pinned `typescript`, read from the packed `package.json`, so the check cannot change verdict without a change to this repository. -- That job `needs` the pack job, so it never races the upload, and the closed - `jobSchema` / `Job` / proof in `fjs/ci/common` are extended to express it - rather than the key being emitted past the schema. +- That job never races the artifact upload — the ordering edge and the CI + generator's ability to express it are owned by + [`../ci/todo/ci-integration-tests.md`](../ci/todo/ci-integration-tests.md). - That job is a required check, so the failure blocks the merge queue. - Both halves are demonstrably falsifiable, each by the input that actually breaks it: dropping the `files` negation reddens the contents assertion, and @@ -543,7 +509,11 @@ type-only and use named `import type { ... }` imports. - [`../../AGENTS.md`](../../AGENTS.md) — root repository policy. - [`../AGENTS.md`](../AGENTS.md) — `fjs/`-specific file/dependency policy. - [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) - — the packed-consumer CI fixture Stage 2 completes. + — the packed-consumer CI fixture Stage 2 completes; it owns the + checkout-less type-check job this design depends on. +- [`../ci/todo/ci-integration-tests.md`](../ci/todo/ci-integration-tests.md) + — owns the `npm pack` artifact hand-off and the CI generator's job-ordering + edge that job rests on. - [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) — upstream JSDoc typedef stripping limitation; superseded as this design's strategy, since no authored `.mjs` declares a typedef to strip. From 8978fdd6fc1ff66949c9a5ac5781040cc5833257 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:06:36 +0000 Subject: [PATCH 195/370] emergent_testing: an unreadable returned tree fails its leaf, not the run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared walk enumerates whatever a proof returned, which runs user code: an enumerable getter or a proxy trap throws inside `collectTests`. Unguarded that unwinds the whole traversal, so one hostile value costs the results of every module that had already passed — including the modules that would have reported failures worth seeing. Adds the `catch` operation that `todo/hostile-proof-values.md` specified, with handlers in the real Node runner (`tryCatch`) and the virtual one (`ok(ok(f()))` — a pure runner still cannot catch, which is the bargain `sandbox` already makes). `sandbox` could not carry this: the virtual runner's is a deliberate pass-through whose thunk answers a `SandboxResult`, so routing a tree walk through it would break every fixture. The read now happens before the leaf is reported, so the failure is part of what gets reported rather than a correction after the fact, and the leaf keeps its own duration while the reporter receives the reading failure to describe. The module's *exported* tree is still read unguarded, deliberately: there is no leaf to attribute it to. That asymmetry is now written down in both the code and the issue. This is also the prerequisite for step 7 of share-browser-console-runner.md — the browser catches this today, so sharing the traversal without it would have lost a behaviour. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/1754.md | 4 + fjs/effects/node/module.f.mjs | 14 +++- fjs/effects/node/module.mjs | 3 + fjs/effects/node/types.ts | 24 ++++++ fjs/effects/node/virtual/module.f.mjs | 5 ++ fjs/emergent_testing/module.f.mjs | 80 ++++++++++++++----- fjs/emergent_testing/proof.f.mjs | 77 +++++++++++++++++- .../todo/hostile-proof-values.md | 21 +++-- .../todo/share-browser-console-runner.md | 9 +++ 9 files changed, 205 insertions(+), 32 deletions(-) create mode 100644 changelog/unreleased/1754.md diff --git a/changelog/unreleased/1754.md b/changelog/unreleased/1754.md new file mode 100644 index 000000000..f8e5c26ec --- /dev/null +++ b/changelog/unreleased/1754.md @@ -0,0 +1,4 @@ +- `emergent_testing`: a proof whose return value cannot be enumerated — an + enumerable getter or a proxy trap that throws — is now reported as that + proof's failure instead of taking the whole run down with it. `effects`: adds + the `catch` operation this needs, beside `sandbox` diff --git a/fjs/effects/node/module.f.mjs b/fjs/effects/node/module.f.mjs index e3bd8e3f6..2acb42437 100644 --- a/fjs/effects/node/module.f.mjs +++ b/fjs/effects/node/module.f.mjs @@ -14,7 +14,7 @@ * @import { Result } from '../../types/result/types.ts' * @import { Commands, CommandSet, Effect, Func, NotImplemented, Operation } from '../types.ts' * @import { List } from '../list/types.ts' - * @import { All, Access, Await, Console, CreateExclusive, CreateServer, Dirent, Engine, Env, Exec, ExecResult, Fetch, FileStat, Forever, Fs, Headers, Http, IncomingMessage, Import, IoChannel, IoError, IoErrorInfo, Listen, MakeDirectoryOptions, Mkdir, Module, Now, NodeOp, NodeProgramOptions, RandomInt, Read, ReadBytes, ReadConsoles, ReadFile, Readdir, ReaddirOptions, RequestListener, Rename, Rm, Sandbox, SandboxResult, Server, ServerResponse, Stat, Test, TestContext, TestFn, Write, WriteBytes, WriteConsoles, WriteFile, _UtfList, _WriteLoop } from './types.ts' + * @import { All, Access, Await, Catch, Console, CreateExclusive, CreateServer, Dirent, Engine, Env, Exec, ExecResult, Fetch, FileStat, Forever, Fs, Headers, Http, IncomingMessage, Import, IoChannel, IoError, IoErrorInfo, Listen, MakeDirectoryOptions, Mkdir, Module, Now, NodeOp, NodeProgramOptions, RandomInt, Read, ReadBytes, ReadConsoles, ReadFile, Readdir, ReaddirOptions, RequestListener, Rename, Rm, Sandbox, SandboxResult, Server, ServerResponse, Stat, Test, TestContext, TestFn, Write, WriteBytes, WriteConsoles, WriteFile, _UtfList, _WriteLoop } from './types.ts' */ import { utf8, utf8ToString } from '../../text/module.f.mjs' @@ -127,7 +127,7 @@ export const isNotFound = ([tag, payload]) => * @type {CommandSet} */ const nodeCommandSet = { - access: null, all: null, await: null, createExclusive: null, + access: null, all: null, await: null, catch: null, createExclusive: null, createServer: null, exec: null, fetch: null, forever: null, import: null, listen: null, memCreate: null, memRead: null, memWrite: null, mkdir: null, now: null, randomInt: null, @@ -452,6 +452,16 @@ export const sandbox = do_('sandbox') /** @type {Func} */ const awaitPromise = do_('await') +// catch + +/** + * Runs a pure thunk, answering `ok(v)` for what it returned and `error(e)` for + * what it threw. See {@link Catch} for why this is not `sandbox`. + * + * @type {Func} + */ +export const catch_ = do_('catch') + /** @type {(p: unknown) => Effect} */ export const awaitIfPromise = p => ioMapStep(awaitPromise(p), ([x]) => x) diff --git a/fjs/effects/node/module.mjs b/fjs/effects/node/module.mjs index 41022718c..294c90ecc 100644 --- a/fjs/effects/node/module.mjs +++ b/fjs/effects/node/module.mjs @@ -494,6 +494,9 @@ const runNodeEffect = asyncRun({ forever: () => new Promise(() => {}), now: async () => ok(now()), sandbox: async f => ok(await sandbox(f)), + // A pure thunk over a value the program already has: no clock, no fixture + // convention, just "did it throw". See `Catch` in ./types.ts. + catch: async f => ok(tryCatch(f)), await: async p => ok(await awaitPromise(p)), write: async (stream, data) => ok(await writeAll(streams[stream], fromVec(data))), read: async () => ok(await readStdinByte()), diff --git a/fjs/effects/node/types.ts b/fjs/effects/node/types.ts index 2855658a0..ee9fe58ec 100644 --- a/fjs/effects/node/types.ts +++ b/fjs/effects/node/types.ts @@ -296,6 +296,29 @@ export type Sandbox = readonly['sandbox', (f: () => T) => OpResult OpResult] +// catch + +/** + * Runs a pure thunk and answers what it did: its value, or the value it threw. + * + * It sits beside {@link Sandbox} and is deliberately *not* it. `sandbox` carries + * a clock and, in the virtual runner, a fixture convention — its handler is a + * pass-through whose thunk is expected to answer a {@link SandboxResult} + * directly, because `../virtual` is `.f.mjs` and FunctionalScript has no + * `try`/`catch` to implement a real one with. Routing a tree walk through + * `sandbox` would hand that handler a thunk answering something else entirely. + * + * This one carries neither, so every runner implements it truthfully: the real + * Node runner and a browser interpreter with `tryCatch`, and the virtual runner + * with `ok(ok(f()))` — a pure runner still cannot catch, so a hostile fixture + * still panics there, which is the same bargain `sandbox` already makes. + * + * It exists because reading a *user* value is an operation, not pure logic: the + * proof traversal enumerates values a test returned, and an enumerable getter or + * a proxy trap in one of them is a failure of that test rather than of the run. + */ +export type Catch = readonly['catch', (f: () => T) => OpResult>] + // Test registration /** @@ -336,6 +359,7 @@ export type NodeOp = | Access | All | Await + | Catch | Fetch | Fs | Http diff --git a/fjs/effects/node/virtual/module.f.mjs b/fjs/effects/node/virtual/module.f.mjs index 496d20596..3e361029a 100644 --- a/fjs/effects/node/virtual/module.f.mjs +++ b/fjs/effects/node/virtual/module.f.mjs @@ -650,6 +650,11 @@ const map = { // See: issues/156-tf-virtual-tests.md sandbox: f => state => [state, ok(/** @type {SandboxResult} */ (f()))], await: p => state => [state, ok([p])], + // A pure runner cannot catch, so this reports what the thunk returned and a + // throw still panics — the same bargain `sandbox` above already makes. + // Virtual proofs use benign fixtures; the hostile ones belong to a runner + // that has a real `try`. See `Catch` in `../types.ts`. + catch: f => state => [state, ok(ok(f()))], write: (stream, data) => state => { const s = utf8ToString(data) return [{ ...state, [stream]: `${state[stream]}${s}` }, okVoid] diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index 303e8915f..58b822cd3 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -11,14 +11,15 @@ * @module * * @import { Operation } from '../effects/types.ts' + * @import { Result } from '../types/result/types.ts' * @import { Effect, NotImplemented } from '../effects/types.ts' * @import { LoadModuleOperations, ModuleMap } from '../dev/types.ts' * @import { TestFn, TestEntry, TestSet, Path, Reporter, RunTotals, TestResult, _TestAndPath } from './types.ts' - * @import { All, Await, Env, IoChannel, NodeProgram, NodeProgramOptions, Program, Sandbox, SandboxResult, Test, TestContext, Write, WriteConsoles } from '../effects/node/types.ts' + * @import { All, Await, Catch, Env, IoChannel, NodeProgram, NodeProgramOptions, Program, Sandbox, SandboxResult, Test, TestContext, Write, WriteConsoles } from '../effects/node/types.ts' */ import { reset, fgGreen, fgRed, bold, csiWrite } from '../text/sgr/module.f.mjs' -import { allOk, awaitIfPromise, errorExit, errorMessage, errorSummary, exitStep, sandbox, test } from '../effects/node/module.f.mjs' +import { allOk, awaitIfPromise, catch_, errorExit, errorMessage, errorSummary, exitStep, sandbox, test } from '../effects/node/module.f.mjs' import { catchStep, history, historyStep, mapStep, pureError, pureOk, resultStep, step, } from '../effects/module.f.mjs' @@ -49,6 +50,14 @@ export const addResult = (totals, r) => ({ duration: totals.duration + r.duration, }) +/** + * The empty entry list, named so the three places that mean "this leaf has no + * sub-tree" share one value rather than three literals. + * + * @type {readonly _TestAndPath[]} + */ +const emptyEntries = [] + /** @type {(a: number) => string} */ const timeFormat = a => { const y = Math.round(a * 10_000).toString() @@ -176,17 +185,47 @@ const mergeTotals = (a, b) => /** * @template {Operation} O * @param {Reporter} reporter - * @returns {(k: string, v: unknown) => (ts: RunTotals) => Effect} + * @returns {(k: string, v: unknown) => (ts: RunTotals) => Effect} */ const runModule = ({ result, test }) => (k, v) => ts => { - /** @type {(entry: _TestAndPath) => Effect} */ + /** @type {(entry: _TestAndPath) => Effect} */ const one = ([testPath, set]) => { // The leaf's shared record is built here, next to the sandbox result it // is read from, so the leaf-landed event carries the value already // decided — a reporter renders `t`, it does not derive its own. - const evaluated = mapStep( - test(k, testPath, set), - sr => /** @type {const} */ ([testResult(k, testPath, sr), sr])) + // + // **Reading the returned sub-tree is guarded, because reading it runs + // user code.** `collectTests` enumerates what the leaf returned, so an + // enumerable getter or a proxy trap in that value throws *here* — and + // that is a failure of the leaf which produced it, not of the run. + // Unguarded it unwinds the whole traversal, taking with it the results + // of every module that had already passed. The read happens before the + // leaf is reported, so its failure is part of what gets reported rather + // than a correction issued after the fact. + const evaluated = step(test(k, testPath, set), sr => { + const t = testResult(k, testPath, sr) + if (t.status !== 'passed' || set.throws) { + return pureOk(/** @type {const} */ ([t, sr, emptyEntries])) + } + // null marks the call boundary, so paths render as + // `outer().inner`; `throws` resets to false inside a return value. + const read = /** @type {Effect, NotImplemented>} */ ( + catch_(() => collectTests([...testPath, null], false, sr.result[1]))) + return mapStep( + read, + r => r[0] === 'ok' + ? /** @type {const} */ ([t, sr, r[1]]) + // The leaf answers for a tree nothing can read. Its own + // duration is kept — that is what running it took — while + // the result handed to the reporter carries the reading + // failure, so a host that describes a thrown value + // describes this one. + : /** @type {const} */ ([ + { ...t, status: 'failed' }, + { ...sr, result: r }, + emptyEntries, + ])) + }) // Both are still needed after they have been reported, so the reporting // call is captured rather than nested inside its own step. const reported = historyStep( @@ -194,24 +233,25 @@ const runModule = ({ result, test }) => (k, v) => ts => { ([t, sr]) => result(t, sr, set.throws)) return step( reported, - ([, [t, sr]]) => { + ([, [t, sr, children]]) => { const total = addResult(zeroTotals, t) - if (t.status !== 'passed' || set.throws) { + if (children.length === 0) { return pureOk(total) } - // Walk return-value sub-tree; null marks the call boundary so - // paths render as e.g. `outer().inner`. throws resets to false. return mapStep( - walk([...testPath, null], false, sr.result[1]), + walkEntries(children), sub => mergeTotals(total, sub)) }) } - /** @type {(path: Path, throws: boolean, v: unknown) => Effect} */ - const walk = (path, throws, v) => { - const effects = collectTests(path, throws, v).map(one) - return mapStep(allOk(...effects), states => states.reduce(mergeTotals, zeroTotals)) - } - return mapStep(walk([], false, v), delta => mergeTotals(ts, delta)) + /** @type {(entries: readonly _TestAndPath[]) => Effect} */ + const walkEntries = entries => + mapStep(allOk(...entries.map(one)), states => states.reduce(mergeTotals, zeroTotals)) + // The *module's* own export is read unguarded, and that asymmetry is + // deliberate rather than an oversight: there is no leaf to attribute it to, + // so an unreadable `proof` export is whatever loaded the module's problem. + // `fjs t` panics on one; the browser page catches it and reports one failed + // module. See `todo/hostile-proof-values.md`. + return mapStep(walkEntries(collectTests([], false, v)), delta => mergeTotals(ts, delta)) } /** @type {(moduleMap: ModuleMap) => readonly (readonly [string, unknown])[]} */ @@ -226,7 +266,7 @@ const proofEntries = moduleMap => * * @template {Operation} O * @param {Reporter} reporter - * @returns {(moduleMap: ModuleMap) => Effect} + * @returns {(moduleMap: ModuleMap) => Effect} */ export const runModuleMap = reporter => moduleMap => { const { summary } = reporter @@ -279,7 +319,7 @@ const exitCodeStep = e => * * @template {Operation} O * @param {Reporter} reporter - * @returns {Program} + * @returns {Program} */ export const testAll = reporter => options => exitCodeStep(step(loadModuleMap(options.env), runModuleMap(reporter))) diff --git a/fjs/emergent_testing/proof.f.mjs b/fjs/emergent_testing/proof.f.mjs index 26a21411f..9dbc79f18 100644 --- a/fjs/emergent_testing/proof.f.mjs +++ b/fjs/emergent_testing/proof.f.mjs @@ -4,8 +4,9 @@ * @import { NodeProgramOptions, OpResult, Sandbox, Write } from '../effects/node/types.ts' * @import { JsModule } from '../effects/node/virtual/types.ts' * @import { Reporter } from './types.ts' - * @import { All, Await, Import, Readdir, Test, TestContext } from '../effects/node/types.ts' + * @import { All, Await, Catch, Import, Readdir, Test, TestContext } from '../effects/node/types.ts' * @import { Ts } from '../rtti/ts/types.ts' + * @import { Vec } from '../types/bit_vec/types.ts' */ import { exitCode } from '../effects/node/module.f.mjs' @@ -14,12 +15,13 @@ import { defaultNodeProgramOptions, emptyState, virtual } from '../effects/node/ import { assert, assertEq, todo } from '../asserts/module.f.mjs' import { testAll, fmtPath, fmtImport, ghEscape, isInteger, isIdentifier, - registerModule, parseTestSet, + registerModule, parseTestSet, runModuleMap, addResult, defaultTest, main, register, testResult, zeroTotals, } from './module.f.mjs' import { run as mockRun } from '../effects/mock/module.f.mjs' import { shouldLoad } from '../dev/module.f.mjs' import { parse as parseJson } from '../media/json/module.f.mjs' +import { utf8ToString } from '../text/module.f.mjs' import { number as rttiNumber, or, string as rttiString } from '../rtti/module.f.mjs' import { parse as rttiParse } from '../rtti/parse/module.f.mjs' import { error, ok, unwrap } from '../types/result/module.f.mjs' @@ -309,7 +311,7 @@ export const githubReporterOutput = () => { // the failure on, so the exit code rather than a message is what is observable: // a run that cannot say anything at all still says it failed. export const reporterWriteFailure = () => { - /** @typedef {All | Import | Readdir | Sandbox | Write} _FailOps */ + /** @typedef {All | Catch | Import | Readdir | Sandbox | Write} _FailOps */ /** @type {RunInstance<_FailOps, undefined>} */ let runner runner = mockRun(/** @type {Parameters>[0]} */ ({ @@ -327,6 +329,10 @@ export const reporterWriteFailure = () => { }, sandbox: (/** @type {() => unknown} */ f) => (/** @type {undefined} */ s) => [s, ok({ result: ok(f()), duration: 0 })], + // Benign, like the virtual runner's: this proof is about a reporter + // that cannot write, and its fixture's tree reads cleanly. + catch: (/** @type {() => unknown} */ f) => (/** @type {undefined} */ s) => + [s, ok(ok(f()))], write: (_stream, _data) => s => [s, error(['notImplemented', 'write'])], })) const [, code] = runner(undefined)( @@ -616,6 +622,70 @@ export const helpers = { }, } +// A leaf whose returned tree cannot be enumerated is that leaf's failure, not +// the run's. Before the `catch` operation existed the throw escaped the +// traversal and took the whole run down — including the modules that had +// already passed and would never be reported. The real runner is used rather +// than the virtual one on purpose: the virtual `catch` cannot catch (it is +// `.f.mjs`), so only a runner with a real `try` can show this. +const returnedTreeThrows = () => { + /** @type {Reporter} */ + const reporter = { + result: (t, _r, _throws) => log(`${t.path}:${t.status}`), + summary: ({ passed, failed }) => log(`summary:${passed}:${failed}`), + test: defaultTest, + } + const hostile = { + good: () => 1, + // Enumerating the returned value runs this getter. + bad: () => ({ get boom() { throw new Error('trap') } }), + } + /** @type {string[]} */ + const lines = [] + /** @type {RunInstance} */ + let runner + runner = mockRun(/** @type {Parameters>[0]} */ ({ + all: (...effects) => s => { + const [st, rs] = effects.reduce( + ([st1, rs1], e) => { + const [ns, r] = runner(st1)(e) + return [ns, [...rs1, r]] + }, + /** @type {readonly [undefined, readonly unknown[]]} */([s, []]), + ) + return [st, ok(rs)] + }, + // The two handlers this proof turns on: a real sandbox and a real + // catch, which is what a `.mjs` runner can offer and `.f.mjs` cannot. + sandbox: (/** @type {() => unknown} */ f) => (/** @type {undefined} */ s) => { + try { + return [s, ok({ result: ok(f()), duration: 0 })] + } catch (e) { + return [s, ok({ result: error(e), duration: 0 })] + } + }, + catch: (/** @type {() => unknown} */ f) => (/** @type {undefined} */ s) => { + try { + return [s, ok(ok(f()))] + } catch (e) { + return [s, ok(error(e))] + } + }, + write: (_stream, /** @type {Vec} */ data) => s => { + lines.push(utf8ToString(data)) + return [s, ok(undefined)] + }, + })) + runner(undefined)(runModuleMap(reporter)({ './h.proof.f.mjs': { proof: hostile } })) + const text = lines.join('') + // The hostile leaf is reported as failed, by its own path... + assert(text.includes('.bad:failed'), text) + // ...and the leaf beside it still ran and was still reported, which is the + // whole point: one bad value no longer costs the rest of the suite. + assert(text.includes('.good:passed'), text) + assert(text.includes('summary:1:1'), text) +} + // a passing throw-test emits '# EXPECTED TO THROW' in its output line const defaultReporterExpectedToThrow = () => { // fail0 returns a SandboxResult indicating an error; in a throw context @@ -709,5 +779,6 @@ export const proof = { registerEmptyModuleMap, registerSelectsContextAndStar, defaultReporterExpectedToThrow, + returnedTreeThrows, helpers } diff --git a/fjs/emergent_testing/todo/hostile-proof-values.md b/fjs/emergent_testing/todo/hostile-proof-values.md index b580bfbad..f23e53c11 100644 --- a/fjs/emergent_testing/todo/hostile-proof-values.md +++ b/fjs/emergent_testing/todo/hostile-proof-values.md @@ -94,13 +94,20 @@ adopting a `then`, and a proof tree refusing to — which are studied together i ### Tasks -- [ ] Add the `catch` operation, its constructor, and a handler in each of the - Node, browser and virtual runners. -- [ ] Read sub-trees through it in `walk`, reporting an unreadable tree as one - failed result at its path rather than a panic. -- [ ] Prove an unreadable exported tree and an unreadable returned tree, for - `fjs t` as well as the browser — the browser has versions of these today - and `fjs t` has none. +- [x] Add the `catch` operation, its constructor, and a handler in the Node and + virtual runners. The browser handler waits for the browser interpreter, + which is where a browser runner will first dispatch one. +- [x] Read the *returned* sub-tree through it in `walk`, reporting an unreadable + tree as that leaf's failure rather than a panic. +- [ ] The **exported** tree is still read unguarded, and deliberately: there is + no leaf to attribute it to, so an unreadable `proof` export belongs to + whatever loaded the module. `fjs t` still panics on one; the browser page + still catches it and reports one failed module. Closing *that* asymmetry + is a report-shape question (what a non-leaf failure is called) rather than + a missing operation, and it is the part of this issue still open. +- [x] Prove an unreadable returned tree for `fjs t` — `returnedTreeThrows` in + `../proof.f.mjs`, which needs a runner with a real `try`, so it drives + `runModuleMap` through a mock rather than through the virtual runner. - [ ] Read a thrown value through it at `errorDetails`' call site. ### Constraints diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 1f6691eba..a7caf9d9f 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -166,6 +166,15 @@ and is reviewable without the next one. [node-module-layering](../../effects/todo/node-module-layering.md) is where those rulings live, and it already declines to move `Now` and `RandomInt` for a related reason. + **The `catch` operation landed first, because step 7 cannot be written + without it.** The shared walk enumerates what a leaf returned, which runs + user code; the browser catches that today and the shared walk did not, so + sharing the traversal would have *lost* a behaviour. `sandbox` could not + hold the guard — the virtual runner's is a fixture pass-through — so + [hostile proof values](hostile-proof-values.md) named a second operation + and this took it. `fjs t` gained the behaviour in the process, which is + what made that change worth landing on its own rather than inside the port. + - [ ] **5. A browser interpreter** for exactly those operations, with no scheduling policy of its own. This is also what earns step 4's *operation* move its second consumer: until a second host implements `sandbox`, From 038bfbc9a5c1d42ad1937d5a16051d02f2abce4e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:07:19 +0000 Subject: [PATCH 196/370] changelog: name the entry by its real PR number Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/{1754.md => 1758.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/unreleased/{1754.md => 1758.md} (100%) diff --git a/changelog/unreleased/1754.md b/changelog/unreleased/1758.md similarity index 100% rename from changelog/unreleased/1754.md rename to changelog/unreleased/1758.md From a99607cdd14ed8280b9456db3a5ffc6bf2a841bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:15:26 +0000 Subject: [PATCH 197/370] emergent_testing: the catching runner belongs in a .mjs proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found try/catch in proof.f.mjs — outside the language the file claims to be written in, and the same defect the repository has been removing rather than excusing. The irony is exact: the proof exists because FunctionalScript cannot catch, and it was written with a catch. `returnedTreeThrows` moves to catch.proof.mjs, where a host runner belongs. Its mock accumulates written output in the runner's own state rather than pushing into a captured array, which was the second finding and is the better shape anyway — the proof now reads like a virtual run. A second proof comes with the move: a readable returned tree is still walked, so the guard did not quietly replace recursion with a refusal to recurse. The changelog entry is marked breaking. Adding `Catch` to `NodeOp` makes a handler mandatory for any exhaustive `NodeOperationMap` — which this change proved by breaking the Node interpreter's typecheck and the mock map in `reporterWriteFailure` before supplying both. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/1758.md | 8 +- fjs/emergent_testing/catch.proof.mjs | 115 +++++++++++++++++++++++++++ fjs/emergent_testing/proof.f.mjs | 69 +--------------- 3 files changed, 120 insertions(+), 72 deletions(-) create mode 100644 fjs/emergent_testing/catch.proof.mjs diff --git a/changelog/unreleased/1758.md b/changelog/unreleased/1758.md index f8e5c26ec..bed7184b4 100644 --- a/changelog/unreleased/1758.md +++ b/changelog/unreleased/1758.md @@ -1,4 +1,4 @@ -- `emergent_testing`: a proof whose return value cannot be enumerated — an - enumerable getter or a proxy trap that throws — is now reported as that - proof's failure instead of taking the whole run down with it. `effects`: adds - the `catch` operation this needs, beside `sandbox` +- **BREAKING CHANGES:** `effects`: the new `catch` operation joins `NodeOp`, so + an exhaustive `NodeOperationMap` must supply a handler for it +- `emergent_testing`: a proof whose return value cannot be enumerated fails that + proof instead of the whole run diff --git a/fjs/emergent_testing/catch.proof.mjs b/fjs/emergent_testing/catch.proof.mjs new file mode 100644 index 000000000..1b40efb53 --- /dev/null +++ b/fjs/emergent_testing/catch.proof.mjs @@ -0,0 +1,115 @@ +/** + * The shared traversal, driven by a runner that can actually catch. + * + * This proof is `.mjs` rather than `.f.mjs` deliberately, and the reason is the + * same one that made the `catch` operation necessary in the first place: a + * runner which reports a throw instead of propagating it needs `try`/`catch` to + * write, and FunctionalScript has neither. `../effects/node/virtual` answers + * `ok(ok(f()))` for exactly that reason, so it cannot demonstrate the behaviour + * — only a host runner can, and a host runner belongs in a host file. + * + * @module + * + * @import { RunInstance } from '../effects/mock/types.ts' + * @import { All, Catch, Sandbox, Write } from '../effects/node/types.ts' + * @import { Reporter } from './types.ts' + * @import { Vec } from '../types/bit_vec/types.ts' + */ + +import { assert } from '../asserts/module.f.mjs' +import { log } from '../effects/node/module.f.mjs' +import { run as mockRun } from '../effects/mock/module.f.mjs' +import { defaultTest, runModuleMap } from './module.f.mjs' +import { error, ok } from '../types/result/module.f.mjs' +import { utf8ToString } from '../text/module.f.mjs' + +/** @typedef {All | Catch | Sandbox | Write} _Ops */ + +/** + * Runs `proof` through the shared traversal on a runner whose `sandbox` and + * `catch` are real, and answers everything the reporter wrote. + * + * The written lines are the runner's *state* rather than a captured array, so + * the proof reads the same way a virtual run does and nothing here mutates a + * value it closed over. + * + * @type {(proof: unknown) => string} + */ +const runWith = proof => { + /** @type {Reporter} */ + const reporter = { + result: (t, _r, _throws) => log(`${t.path}:${t.status}`), + summary: ({ passed, failed }) => log(`summary:${passed}:${failed}`), + test: defaultTest, + } + /** @type {RunInstance<_Ops, string>} */ + let runner + runner = mockRun(/** @type {Parameters>[0]} */ ({ + all: (...effects) => s => { + const [st, rs] = effects.reduce( + ([st1, rs1], e) => { + const [ns, r] = runner(st1)(e) + return [ns, [...rs1, r]] + }, + /** @type {readonly [string, readonly unknown[]]} */ ([s, []]), + ) + return [st, ok(rs)] + }, + // The two handlers this proof turns on. A real `try` is what + // `../effects/node/virtual` cannot offer and what the Node runner does. + sandbox: (/** @type {() => unknown} */ f) => (/** @type {string} */ s) => { + try { + return [s, ok({ result: ok(f()), duration: 0 })] + } catch (e) { + return [s, ok({ result: error(e), duration: 0 })] + } + }, + catch: (/** @type {() => unknown} */ f) => (/** @type {string} */ s) => { + try { + return [s, ok(ok(f()))] + } catch (e) { + return [s, ok(error(e))] + } + }, + write: (_stream, /** @type {Vec} */ data) => (/** @type {string} */ s) => + [s + utf8ToString(data), ok(undefined)], + })) + const [written] = runner('')(runModuleMap(reporter)({ './h.proof.f.mjs': { proof } })) + return written +} + +/** + * A leaf whose returned tree cannot be enumerated is that leaf's failure, not + * the run's. + * + * Before the `catch` operation the throw escaped the traversal and took the + * whole run with it — including the modules that had already passed and would + * now never be reported. That is what the second assertion is for: `good` is + * not incidental company, it is the part that used to be lost. + */ +const returnedTreeThrows = () => { + const written = runWith({ + good: () => 1, + // Enumerating the returned value runs this getter. + bad: () => ({ get boom() { throw new Error('trap') } }), + }) + assert(written.includes('.bad:failed'), written) + assert(written.includes('.good:passed'), written) + assert(written.includes('summary:1:1'), written) +} + +/** + * A readable returned tree is still walked, so the guard did not replace the + * recursion with a refusal to recurse. + */ +const returnedTreeIsStillWalked = () => { + const written = runWith({ outer: () => ({ inner: () => 1 }) }) + assert(written.includes('.outer:passed'), written) + assert(written.includes('.outer().inner:passed'), written) + assert(written.includes('summary:2:0'), written) +} + +export const proof = { + returnedTreeThrows, + returnedTreeIsStillWalked, +} diff --git a/fjs/emergent_testing/proof.f.mjs b/fjs/emergent_testing/proof.f.mjs index 9dbc79f18..ad984cf5b 100644 --- a/fjs/emergent_testing/proof.f.mjs +++ b/fjs/emergent_testing/proof.f.mjs @@ -6,7 +6,6 @@ * @import { Reporter } from './types.ts' * @import { All, Await, Catch, Import, Readdir, Test, TestContext } from '../effects/node/types.ts' * @import { Ts } from '../rtti/ts/types.ts' - * @import { Vec } from '../types/bit_vec/types.ts' */ import { exitCode } from '../effects/node/module.f.mjs' @@ -15,13 +14,12 @@ import { defaultNodeProgramOptions, emptyState, virtual } from '../effects/node/ import { assert, assertEq, todo } from '../asserts/module.f.mjs' import { testAll, fmtPath, fmtImport, ghEscape, isInteger, isIdentifier, - registerModule, parseTestSet, runModuleMap, + registerModule, parseTestSet, addResult, defaultTest, main, register, testResult, zeroTotals, } from './module.f.mjs' import { run as mockRun } from '../effects/mock/module.f.mjs' import { shouldLoad } from '../dev/module.f.mjs' import { parse as parseJson } from '../media/json/module.f.mjs' -import { utf8ToString } from '../text/module.f.mjs' import { number as rttiNumber, or, string as rttiString } from '../rtti/module.f.mjs' import { parse as rttiParse } from '../rtti/parse/module.f.mjs' import { error, ok, unwrap } from '../types/result/module.f.mjs' @@ -622,70 +620,6 @@ export const helpers = { }, } -// A leaf whose returned tree cannot be enumerated is that leaf's failure, not -// the run's. Before the `catch` operation existed the throw escaped the -// traversal and took the whole run down — including the modules that had -// already passed and would never be reported. The real runner is used rather -// than the virtual one on purpose: the virtual `catch` cannot catch (it is -// `.f.mjs`), so only a runner with a real `try` can show this. -const returnedTreeThrows = () => { - /** @type {Reporter} */ - const reporter = { - result: (t, _r, _throws) => log(`${t.path}:${t.status}`), - summary: ({ passed, failed }) => log(`summary:${passed}:${failed}`), - test: defaultTest, - } - const hostile = { - good: () => 1, - // Enumerating the returned value runs this getter. - bad: () => ({ get boom() { throw new Error('trap') } }), - } - /** @type {string[]} */ - const lines = [] - /** @type {RunInstance} */ - let runner - runner = mockRun(/** @type {Parameters>[0]} */ ({ - all: (...effects) => s => { - const [st, rs] = effects.reduce( - ([st1, rs1], e) => { - const [ns, r] = runner(st1)(e) - return [ns, [...rs1, r]] - }, - /** @type {readonly [undefined, readonly unknown[]]} */([s, []]), - ) - return [st, ok(rs)] - }, - // The two handlers this proof turns on: a real sandbox and a real - // catch, which is what a `.mjs` runner can offer and `.f.mjs` cannot. - sandbox: (/** @type {() => unknown} */ f) => (/** @type {undefined} */ s) => { - try { - return [s, ok({ result: ok(f()), duration: 0 })] - } catch (e) { - return [s, ok({ result: error(e), duration: 0 })] - } - }, - catch: (/** @type {() => unknown} */ f) => (/** @type {undefined} */ s) => { - try { - return [s, ok(ok(f()))] - } catch (e) { - return [s, ok(error(e))] - } - }, - write: (_stream, /** @type {Vec} */ data) => s => { - lines.push(utf8ToString(data)) - return [s, ok(undefined)] - }, - })) - runner(undefined)(runModuleMap(reporter)({ './h.proof.f.mjs': { proof: hostile } })) - const text = lines.join('') - // The hostile leaf is reported as failed, by its own path... - assert(text.includes('.bad:failed'), text) - // ...and the leaf beside it still ran and was still reported, which is the - // whole point: one bad value no longer costs the rest of the suite. - assert(text.includes('.good:passed'), text) - assert(text.includes('summary:1:1'), text) -} - // a passing throw-test emits '# EXPECTED TO THROW' in its output line const defaultReporterExpectedToThrow = () => { // fail0 returns a SandboxResult indicating an error; in a throw context @@ -779,6 +713,5 @@ export const proof = { registerEmptyModuleMap, registerSelectsContextAndStar, defaultReporterExpectedToThrow, - returnedTreeThrows, helpers } From b6acda8055445ccbedf5ac7c1eab7712892c4a98 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:18:19 +0000 Subject: [PATCH 198/370] emergent_testing: inline the ops union, no file-scope typedef in .mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's own lint caught what my local checks did not: no authored .mjs may carry a file-scope JSDoc @typedef, so the `_Ops` alias in the new proof failed the repository grep. The rule offers a sibling private.ts or inlining; a four-member union named at two annotations is the inlining case, and adding a file for it would be the heavier answer. The gap was in how I verified: tsc, fjs t and coverage all passed locally while the lint steps in the workflow went unrun. Both of those steps — the typedef grep and ci-update's no-diff check — now pass here before pushing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/emergent_testing/catch.proof.mjs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/fjs/emergent_testing/catch.proof.mjs b/fjs/emergent_testing/catch.proof.mjs index 1b40efb53..f2b11572a 100644 --- a/fjs/emergent_testing/catch.proof.mjs +++ b/fjs/emergent_testing/catch.proof.mjs @@ -23,8 +23,6 @@ import { defaultTest, runModuleMap } from './module.f.mjs' import { error, ok } from '../types/result/module.f.mjs' import { utf8ToString } from '../text/module.f.mjs' -/** @typedef {All | Catch | Sandbox | Write} _Ops */ - /** * Runs `proof` through the shared traversal on a runner whose `sandbox` and * `catch` are real, and answers everything the reporter wrote. @@ -42,9 +40,9 @@ const runWith = proof => { summary: ({ passed, failed }) => log(`summary:${passed}:${failed}`), test: defaultTest, } - /** @type {RunInstance<_Ops, string>} */ + /** @type {RunInstance} */ let runner - runner = mockRun(/** @type {Parameters>[0]} */ ({ + runner = mockRun(/** @type {Parameters>[0]} */ ({ all: (...effects) => s => { const [st, rs] = effects.reduce( ([st1, rs1], e) => { From dc75774308365751d73367685aa2d306e070ffee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:23:02 +0000 Subject: [PATCH 199/370] emergent_testing: a proof file carries no @module tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md reserves @module for a package's entry point — module.f.mjs or module.mjs — and the new proof had one. The leading block stays, as the same rule requires, because it holds the @import tags. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/emergent_testing/catch.proof.mjs | 2 -- 1 file changed, 2 deletions(-) diff --git a/fjs/emergent_testing/catch.proof.mjs b/fjs/emergent_testing/catch.proof.mjs index f2b11572a..88ad96a3c 100644 --- a/fjs/emergent_testing/catch.proof.mjs +++ b/fjs/emergent_testing/catch.proof.mjs @@ -8,8 +8,6 @@ * `ok(ok(f()))` for exactly that reason, so it cannot demonstrate the behaviour * — only a host runner can, and a host runner belongs in a host file. * - * @module - * * @import { RunInstance } from '../effects/mock/types.ts' * @import { All, Catch, Sandbox, Write } from '../effects/node/types.ts' * @import { Reporter } from './types.ts' From 31bffb614f37487a9e44e35c8afe0829d82790c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:29:13 +0000 Subject: [PATCH 200/370] todo: the negative control should be organic, and it works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review's closing note was right that the three measurements so far inject the failure into an already-packed declaration: they show the check can fail, not that this repository's workflow could produce the artifact that fails it. It can. The organic control is a source-level violation of the public-declaration-closure rule — exporting a binding whose signature names a private type. Run end to end with `export const divide` in fjs/types/bigfloat/module.f.mjs: ordinary prepack emits a real `import type { _BigFloatWithRemainder } from './private.ts'` rather than the inlined structural type; npm pack with the files negation ships 0 private.d.ts; installing that tarball and type-checking all 377 declarations exits 2 with TS2307 on that line. Two consequences recorded. The check's real target is a closure-rule violation reaching an exported signature — today none does, because every binding annotated with a private type is module-private, which is why the tree measures clean. And the fixture's control should be this source-level one: it exercises emit, packing and consumption together, so it also fails if a future TypeScript starts inlining the reference and the design's premise quietly stops holding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/todo/separate-private-types.md | 33 +++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 70894c682..771840493 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -300,6 +300,30 @@ scratch consumer and the 16 `private.d.ts` removed from it: `2`. A fixed import list would have shipped a check that cannot see the case it exists to catch. +Those three inject the failure into an already-packed declaration, which shows +the check can fail but not that this repository's own workflow could *produce* +the artifact that fails it. It can, and the whole design was then run end to +end against it. The organic control is a source-level violation of the +public-declaration-closure rule — exporting a binding whose signature names a +private type, here `export const divide` in `fjs/types/bigfloat/module.f.mjs`, +typed `_BigFloatWithRemainder`: + +1. ordinary `prepack` emits a **real** `import type { _BigFloatWithRemainder } + from './private.ts'` into `module.f.d.mts` — not the inlined structural type, + and the specifier keeps its `.ts` extension, as declaration emit does for + `types.ts`; +2. `npm pack` with the `files` negation ships **0** `private.d.ts`; +3. installing that tarball and type-checking all 377 declarations exits `2` + with `TS2307` naming that line. + +Two things follow. The check's real target is a closure-rule violation reaching +an exported signature — today no private type does, because every binding +annotated with one is module-private, which is why the tree measures clean. And +the control to write into the fixture is this source-level one, not an edit to +the packed output: it exercises emit, packing and consumption together, so it +also fails if a future TypeScript starts inlining the reference and the design's +premise quietly stops holding. + The job is added through the CI generator (`fjs/ci/**`, composed in `fjs/ci/module.f.mjs`), never by editing `.github/workflows/ci.yml`, which `npm run ci-update` regenerates. @@ -428,9 +452,12 @@ type-only and use named `import type { ... }` imports. the type-check green. The type-check's control is the reverse — a packed declaration that references a private module the tarball does not carry (a shipped declaration made to depend on `private.ts`, with the negation - still in place), which resolves in-repo and dangles once packed. Place - that control in a module with **no** `private.ts` today, so it also - proves the check is exhaustive rather than pinned to today's surfaces. + still in place), which resolves in-repo and dangles once packed. Make it + a **source-level** violation — an exported binding whose signature names a + private type — not an edit to the packed output, so the control exercises + emit, packing and consumption together; measured end to end above. Place + it in a module with **no** `private.ts` today, so it also proves the check + is exhaustive rather than pinned to today's surfaces. - [ ] Add fixtures covering packaging: retained non-semantic JSDoc `@import` comments in emitted declarations, absent private artifacts in the tarball, and a clean package consumer. From 912c71feb1315400445eae8cc989a76d4dea8bd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:31:17 +0000 Subject: [PATCH 201/370] todo: returnedTreeThrows moved, and the name is now shared on purpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the task list still placing the fjs t proof in proof.f.mjs, which is where it started and not where it ended up. It names catch.proof.mjs now, with the reason the file is .mjs at all. Also disambiguates the design section: the exportedTreeThrows / returnedTreeThrows it refers to are the browser's, and the fjs t proof deliberately reuses one of those names — one behaviour, named once, proven per runner. The other proof.f.mjs reference in the file is about the virtual runner's fixtures and stays as it was. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/emergent_testing/todo/hostile-proof-values.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/fjs/emergent_testing/todo/hostile-proof-values.md b/fjs/emergent_testing/todo/hostile-proof-values.md index f23e53c11..540f7aa32 100644 --- a/fjs/emergent_testing/todo/hostile-proof-values.md +++ b/fjs/emergent_testing/todo/hostile-proof-values.md @@ -79,9 +79,11 @@ fixture convention, so each runner implements it truthfully: bargain `sandbox` already makes. Virtual proofs use benign fixtures. `walk` then reads a sub-tree through `catch` and, on the `error` branch, reports -one failed result at that path instead of panicking — which is what restores -`exportedTreeThrows` / `returnedTreeThrows`, and gives `fjs t` a behaviour it -never had. `errorDetails` gets the same treatment at its one call site. +one failed result at that path instead of panicking — which is what preserves +the browser's `exportedTreeThrows` / `returnedTreeThrows` (`../browser/proof.mjs`) +once the traversal is shared, and gives `fjs t` a behaviour it never had. The +`fjs t` proof carries the same name deliberately, in `../catch.proof.mjs`: one +behaviour, named once, proven per runner. `errorDetails` gets the same treatment at its one call site. The work is roughly: the operation and its constructor beside `sandbox`, one handler in each runner, the `CommandSet` entries, the `walk` change and its new @@ -106,8 +108,11 @@ adopting a `then`, and a proof tree refusing to — which are studied together i is a report-shape question (what a non-leaf failure is called) rather than a missing operation, and it is the part of this issue still open. - [x] Prove an unreadable returned tree for `fjs t` — `returnedTreeThrows` in - `../proof.f.mjs`, which needs a runner with a real `try`, so it drives - `runModuleMap` through a mock rather than through the virtual runner. + `../catch.proof.mjs`, beside `returnedTreeIsStillWalked`. The file is + `.mjs` for the reason this whole issue rests on: a runner that reports a + throw needs a real `try` to write, so it drives `runModuleMap` through a + mock rather than through the virtual runner, and a mock like that cannot + be written in `.f.mjs`. - [ ] Read a thrown value through it at `errorDetails`' call site. ### Constraints From 3dc5cc74c95a092976298c5ff4fcecfecf3635f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:31:51 +0000 Subject: [PATCH 202/370] =?UTF-8?q?edag/todo:=20exact=20citations=20?= =?UTF-8?q?=E2=80=94=20AGENTS.md=20=C2=A75,=20amnesia's=20verbatim=20discl?= =?UTF-8?q?aimer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes from the approving human review on #1755: the Merge-the- knowledge paragraph is AGENTS.md §5, not §1; amnesia's README is quoted verbatim with the 'deliberately' phrasing attributed to the edag README where it lives; and the identity-aware-parse Related bullet no longer implies a runtime hole check exists to join — rejection is structural under arity-split. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/todo/option-terminated-lambdas.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md index 9ce3ea123..388fb5b14 100644 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ b/fjs/edag/todo/option-terminated-lambdas.md @@ -107,7 +107,7 @@ are not an elision: an array has no holes" even *evaluates* identically to absence (reading it yields `undefined`), so the leak is canonicality-only. It is still a validation regression against today's schema, and a regression may not be deferred behind a todo -([`AGENTS.md`](../../../AGENTS.md) §1, "Merge the knowledge"): the migration +([`AGENTS.md`](../../../AGENTS.md) §5, "Merge the knowledge"): the migration does not land unless the same change keeps `validate(exp)` rejecting a trailing hole, pinned in the proofs. @@ -166,9 +166,10 @@ walkers destructure **today**, so an iterator-hostile step already misleads the current evaluator identically; the migration changes nothing about that class, and `skip` joining the module's one uniform pattern adds no sensitivity the module does not already have. Amnesia's own README -scopes it: a tree-walking evaluator for testing the semantics, -"deliberately **not** a VM to run FunctionalScript on" — its guarantees -assume a DJS value on a pristine host, where every read style coincides. +scopes it — "It is not a VM for FunctionalScript, and nothing that matters +should run on it" — and [`../README.md`](../README.md) adds the intent, +"deliberately not a VM to run FunctionalScript on": its guarantees assume +a DJS value on a pristine host, where every read style coincides. The gate's claim is therefore about the value's **own** members under rtti's stated reading model; hermetic reads for hostile hosts beyond that are rtti's tracked question @@ -266,4 +267,6 @@ carries `option` admits the absent member — and its hole — again. - 7852819 / 930fa65 (#1725) — closed containers by default, then `option` as omission; this issue is that plan's second half applied to edag - [`../../rtti/todo/identity-aware-parse.md`](../../rtti/todo/identity-aware-parse.md) - — the Stage 2 validator the hole check could join + — the identity caveats `validate(exp)` keeps either way; hole rejection + itself is structural under arity-split, pinned in the proofs, and joins + nothing From a201e03d9b70568a1081d4741154983041ca2267 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:32:59 +0000 Subject: [PATCH 203/370] todo: the packaging issue still prescribed deleting private.d.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f-mjs-package-support.md said "Deleting generated private.d.ts before packaging is the second stage of separate-private-types.md" — the superseded design. Now that the owning issue is where an implementer starts, that contradiction is the exact failure the ownership split was meant to prevent, one commit later. It now prescribes the files negation, says prepack is unchanged and the working tree untouched, says explicitly not to reintroduce a deletion step, and notes that once it lands private.d.ts leaves the package-private set entirely, so the leak tolerance narrows to the _-prefixed names in types.d.ts. Also corrects that file's Related entry for microsoft/TypeScript#46407, still listed as an upstream blocker: Stage 1 removed every file-scope JSDoc typedef, so there is nothing left for stripInternal to strip. Swept the repository for other copies: fjs/fsc/README.md describes the outcome ("removing shipped private declaration artifacts from the package") rather than a mechanism, so it needs no change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/todo/f-mjs-package-support.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/fjs/ci/todo/f-mjs-package-support.md b/fjs/ci/todo/f-mjs-package-support.md index 7d7929066..2de990629 100644 --- a/fjs/ci/todo/f-mjs-package-support.md +++ b/fjs/ci/todo/f-mjs-package-support.md @@ -166,9 +166,14 @@ exposes private types as `_`-prefixed names in `types.d.ts` and as generated `private.d.ts` files. Both are package-private by contract, not public API: clean-consumer tests must exercise documented public types and must not turn `_`-prefixed declaration artifacts into supported API merely because TypeScript -emitted them. Deleting generated `private.d.ts` before packaging is the second -stage of -[`fjs/todo/separate-private-types.md`](../../todo/separate-private-types.md). +emitted them. Unshipping generated `private.d.ts` is the second stage of +[`fjs/todo/separate-private-types.md`](../../todo/separate-private-types.md), +by a `!**/private.d.ts` negation in `package.json`'s `files` — an exclusion at +pack time, with `prepack` unchanged and the working tree left alone. An earlier +draft of that design deleted the files instead; do not reintroduce a deletion +step. Once it lands, `private.d.ts` is no longer among the package-private +artifacts above — only the `_`-prefixed names in `types.d.ts` remain, and the +leak-tolerance contract narrows to them. Package selection does not need to distinguish every authored `.mjs` by public API status during this transition. Incidental authored files such as @@ -370,7 +375,8 @@ not, and the pipeline is simplified accordingly. — private-type placement rules and the packaging stage that unships generated private declarations. - [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) - — upstream blocker for stripping private JSDoc typedefs. + — upstream JSDoc typedef stripping limitation; no longer a blocker here, since + no authored `.mjs` declares a file-scope typedef to strip. - [`publishing-packages.md`](./publishing-packages.md) — broader package roadmap. - [`f-js-package-support.md`](./f-js-package-support.md) — stage-2 authored `.f.js` package prerequisite. From 16d59f98fa79ce484817d10016fa9feb70a93df7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:35:14 +0000 Subject: [PATCH 204/370] edag/todo: the migration ships a BREAKING CHANGES changelog entry Review finding on #1755: the implementation changes accepted graphs and the exported types, so per AGENTS.md \u00a75 it needs a changelog/unreleased/.md entry prefixed BREAKING CHANGES with the matching Changelog section, updating every importer in the same PR. Added as a task. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/todo/option-terminated-lambdas.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md index 388fb5b14..47e2eac4f 100644 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ b/fjs/edag/todo/option-terminated-lambdas.md @@ -256,6 +256,10 @@ carries `option` admits the absent member — and its hole — again. [`../../../todo/blocked/bun-optional-chain-parentheses.md`](../../../todo/blocked/bun-optional-chain-parentheses.md), and whatever else a sweep for chain spellings finds — released `changelog/` entries stay as written, history rather than prescription +- [ ] `changelog/unreleased/.md`, prefixed `**BREAKING CHANGES:**`, with + the matching `Changelog:` section in the PR description ([`AGENTS.md`](../../../AGENTS.md) + §5): previously valid graphs and the exported types stop accepting the + `null`-terminated shape, and every importer is updated in the same PR ## Related From f55f58f6e723ed7b97ce299d633deb0447cc6551 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:39:28 +0000 Subject: [PATCH 205/370] todo: the package fixture needs its proof, and must itself conform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retargeted fixture named module.f.mjs with types.ts and private.ts but no proof.f.mjs. fjs/AGENTS.md §1.2 requires 100% proof coverage for every authored .f.mjs, so implementing it literally would fail npm run cov and put the repository in violation of its own rule while demonstrating package support. Adding the proof surfaced a second constraint the task did not state. The fixture carries a private.ts for the packed-declaration check, so it is tempting to have it export a private-typed binding — but that is exactly the failure the check reports, and it would redden the check permanently. The fixture must be a conforming module, private type out of every exported signature, like the rest of the tree; the falsifiability control is a deliberate temporary violation applied while verifying the check can fail, which is how it was measured end to end. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/todo/f-mjs-package-support.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/fjs/ci/todo/f-mjs-package-support.md b/fjs/ci/todo/f-mjs-package-support.md index 2de990629..693c6b249 100644 --- a/fjs/ci/todo/f-mjs-package-support.md +++ b/fjs/ci/todo/f-mjs-package-support.md @@ -219,10 +219,26 @@ emission, `npm pack`, and a clean consumer. outputs. - [ ] Keep package/publish jobs on a clean CI checkout; do not add generated output tracking or cleanup for artifacts from previous revisions. -- [ ] Add a package fixture in the current source model — `module.f.mjs` with an - authored `types.ts` and, for the private-declaration check, a sibling - `private.ts` (authored implementation and proof `.f.ts` are retired, so - the fixture must not reintroduce them). Scope: the fixture exercises the +- [ ] Add a package fixture in the current source model — `module.f.mjs` with a + co-located `proof.f.mjs`, an authored `types.ts` and, for the + private-declaration check, a sibling `private.ts` (authored + implementation and proof `.f.ts` are retired, so the fixture must not + reintroduce them). The proof is not optional paperwork: `fjs/AGENTS.md` + §1.2 requires 100% proof coverage for every authored `.f.mjs`, so a + fixture without one fails `npm run cov` and lands the repository in + violation of its own rule — while demonstrating package support. + Two constraints follow from what the fixture is *for*: + - It must be a **conforming** module: its private type stays out of every + exported signature, matching the public-declaration-closure rule and + the rest of the tree. A fixture that exports a private-typed binding + would permanently redden the packed-declaration check it exists to + support. + - The falsifiability control is therefore a *deliberate, temporary* + violation applied when verifying the check can fail — export a binding + whose signature names the private type — not the fixture's steady + state. Measured to work end to end in + [`../../todo/separate-private-types.md`](../../todo/separate-private-types.md). + Scope: the fixture exercises the supported, fully erased `import type` form only. The forbidden inline `import { type X }` / `import * as` / side-effect forms are a documented one-time measurement ([`packed-consumer-validation.md`](../packed-consumer-validation.md), From 15138264c212d5622d9941fbd057f2d2cbf44f35 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:43:11 +0000 Subject: [PATCH 206/370] todo: falsifiability and exhaustiveness need separate controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f55f58f put the temporary violation in the package fixture. But the fixture is precisely the module a hand-written import list would name, so a violation there fails under a fixed list too — it proves the check reports a dangling reference and says nothing about whether the file set was enumerated. That also contradicted the parent's requirement to place the control in a module with no private surface. Both documents now keep the two questions apart: - Can it fail? Any module with a private.ts; measured in fjs/types/bigfloat. - Is it exhaustive? A module with no private.ts today — which means temporarily giving one to a module that has none — and specifically not the fixture. Measured in fjs/emergent_testing. This matches how they were actually measured; the previous wording implied one control could answer both, which is the mistake it would have taught an implementer to make. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/todo/f-mjs-package-support.md | 18 +++++++++++++----- fjs/todo/separate-private-types.md | 21 ++++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/fjs/ci/todo/f-mjs-package-support.md b/fjs/ci/todo/f-mjs-package-support.md index 693c6b249..b91b15abd 100644 --- a/fjs/ci/todo/f-mjs-package-support.md +++ b/fjs/ci/todo/f-mjs-package-support.md @@ -233,11 +233,19 @@ emission, `npm pack`, and a clean consumer. the rest of the tree. A fixture that exports a private-typed binding would permanently redden the packed-declaration check it exists to support. - - The falsifiability control is therefore a *deliberate, temporary* - violation applied when verifying the check can fail — export a binding - whose signature names the private type — not the fixture's steady - state. Measured to work end to end in - [`../../todo/separate-private-types.md`](../../todo/separate-private-types.md). + - Any violation is therefore *deliberate and temporary*, applied while + verifying the check and then reverted — never the fixture's steady + state. Two different controls are needed, and they must not be run in + the same place: + - **Can the check fail at all?** Export a binding whose signature names + the private type, here in the fixture, and confirm `TS2307`. + - **Is the check exhaustive?** This one must go in a module the + consumer would *not* name — one with no private surface today, and + in particular **not** this fixture. A hand-written import list would + name the fixture, so a violation placed here fails under a fixed list + too and proves nothing about enumeration. Measured end to end with + `fjs/emergent_testing` in + [`../../todo/separate-private-types.md`](../../todo/separate-private-types.md). Scope: the fixture exercises the supported, fully erased `import type` form only. The forbidden inline `import { type X }` / `import * as` / side-effect forms are a documented one-time measurement diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 771840493..029a8b76d 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -455,9 +455,18 @@ type-only and use named `import type { ... }` imports. still in place), which resolves in-repo and dangles once packed. Make it a **source-level** violation — an exported binding whose signature names a private type — not an edit to the packed output, so the control exercises - emit, packing and consumption together; measured end to end above. Place - it in a module with **no** `private.ts` today, so it also proves the check - is exhaustive rather than pinned to today's surfaces. + emit, packing and consumption together; measured end to end above. + Falsifiability and exhaustiveness are separate questions and were + measured separately, so keep them separate here too: + - *Can it fail?* Any module with a `private.ts` will do; measured in + `fjs/types/bigfloat`. + - *Is it exhaustive?* The violation has to land where a hand-written + import list would not look — a module with **no** `private.ts` today, + which means temporarily giving one to a module that has none. It must + also not be the package fixture, since any plausible import list names + that. Measured with `fjs/emergent_testing`. + Running only the first proves the check reports a dangling reference; it + says nothing about whether the file set was enumerated or hard-coded. - [ ] Add fixtures covering packaging: retained non-semantic JSDoc `@import` comments in emitted declarations, absent private artifacts in the tarball, and a clean package consumer. @@ -521,8 +530,10 @@ type-only and use named `import type { ... }` imports. - Both halves are demonstrably falsifiable, each by the input that actually breaks it: dropping the `files` negation reddens the contents assertion, and a packed declaration depending on a private module the tarball does not carry - — placed in a module that has no `private.ts` today — reddens the - declaration type-check. + reddens the declaration type-check. +- Exhaustiveness is demonstrated separately from falsifiability, by a violation + in a module that has no `private.ts` today and is not the package fixture — + anywhere a fixed import list would already look proves only the latter. - The CI job is generated from `fjs/ci/**`, so `npm run ci-update` reproduces `.github/workflows/ci.yml` byte-identically. - `fjs/fsc/README.md` no longer needs tolerance for a shipped `private.d.ts`, From 2dac7d5efbd97c6c9a8a7a92d6771c73f1a44377 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 17:53:11 +0000 Subject: [PATCH 207/370] todo: record that in-repo gates stay green, and align the leak tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two points from review. The organic control's most useful property was implicit: with the violation in place, npx tsc exits 0 and npm pack succeeds. Verified here directly rather than taken from the review. That is this section's opening claim — the exclusion is invisible to every check the repository has — demonstrated instead of argued: the artifact is already broken while nothing in the repository can say so. It is the whole case for a consumer-side job, so it belongs in the measurements. a201e03 also narrowed the leak-tolerance contract to "the _-prefixed names in types.d.ts". Coherent inside its own enumeration but narrower than separate-private-types.md and fsc/README.md, which also cover exported _ constants emitted into module.d.mts. Now says both, and points at fsc/README.md for the contract itself so the three cannot drift apart again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/todo/f-mjs-package-support.md | 7 +++++-- fjs/todo/separate-private-types.md | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/fjs/ci/todo/f-mjs-package-support.md b/fjs/ci/todo/f-mjs-package-support.md index b91b15abd..6e6dce9a1 100644 --- a/fjs/ci/todo/f-mjs-package-support.md +++ b/fjs/ci/todo/f-mjs-package-support.md @@ -172,8 +172,11 @@ by a `!**/private.d.ts` negation in `package.json`'s `files` — an exclusion at pack time, with `prepack` unchanged and the working tree left alone. An earlier draft of that design deleted the files instead; do not reintroduce a deletion step. Once it lands, `private.d.ts` is no longer among the package-private -artifacts above — only the `_`-prefixed names in `types.d.ts` remain, and the -leak-tolerance contract narrows to them. +artifacts above — what remains is the `_`-prefixed names that still ship by +design: `_` types emitted into `types.d.ts` and exported `_` constants emitted +into `module.d.mts`. The leak-tolerance contract narrows to those, and stays +permanent for them; see +[`../../fsc/README.md`](../../fsc/README.md) for the contract itself. Package selection does not need to distinguish every authored `.mjs` by public API status during this transition. Incidental authored files such as diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md index 029a8b76d..de29bf79c 100644 --- a/fjs/todo/separate-private-types.md +++ b/fjs/todo/separate-private-types.md @@ -314,7 +314,12 @@ typed `_BigFloatWithRemainder`: `types.ts`; 2. `npm pack` with the `files` negation ships **0** `private.d.ts`; 3. installing that tarball and type-checking all 377 declarations exits `2` - with `TS2307` naming that line. + with `TS2307` naming that line; +4. and throughout, **every in-repo gate stays green** — `npx tsc` exits `0` and + `npm pack` succeeds with the violation in place. That is the claim at the + top of this section, that the exclusion is invisible to every check the + repository has, demonstrated rather than argued: the artifact is already + broken while nothing in the repository can say so. Two things follow. The check's real target is a closure-rule violation reaching an exported signature — today no private type does, because every binding From 61f990e9356f33a3ad10f7d9026f6395b302f125 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 18:52:35 +0000 Subject: [PATCH 208/370] emergent_testing: the browser runs the shared traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 5 and 7 of todo/share-browser-console-runner.md, together, because neither has a consumer without the other: an interpreter nothing runs is speculative, and the page cannot run the shared walk without one. fjs/effects/browser/module.mjs implements sandbox, catch and all — and nothing else. That set is a measurement, not a starting point, and it settles the question step 4 and effects/todo/node-module-layering.md had recorded as unsettled: await belongs to the registration path no browser runs, and the page loads modules, measures its clock and fetches nothing through its own impure shell rather than through operations. So now, fetch and import stay in effects/node. Both files now say so, including that the earlier guess there was wrong about two of them — reasoning from what a host *can* do predicted one answer, reading what the interpreter had to implement gave another. browser.mjs no longer discovers leaves, applies the throw expectation, walks return values or counts anything. It supplies a Reporter and an interpreter. Its batchSize = 25 and setTimeout yield are deleted: this issue said the batching should be decided at this step rather than inherited, nothing had asked for it, and it was the origin of six review rounds in the reverted attempt. The skeleton grew one thing rather than the browser keeping one: the traversal threads a RunOutcome, the folded totals plus each host's own leaf records in the walk's order. The browser's report needs its results ordered by structure, and completion order would have pinned the scheduler's behaviour instead of the suite's. fjs t answers void there and collects nothing. Reading a module's exported tree stays the page's own guard, as before: there is no leaf to attribute that failure to. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/tmp.md | 3 + fjs/effects/browser/module.mjs | 105 ++++++++ fjs/effects/todo/node-module-layering.md | 41 +-- fjs/emergent_testing/browser.mjs | 237 ++++++++---------- fjs/emergent_testing/module.f.mjs | 86 +++++-- .../todo/share-browser-console-runner.md | 73 ++++-- fjs/emergent_testing/types.ts | 46 +++- 7 files changed, 389 insertions(+), 202 deletions(-) create mode 100644 changelog/unreleased/tmp.md create mode 100644 fjs/effects/browser/module.mjs diff --git a/changelog/unreleased/tmp.md b/changelog/unreleased/tmp.md new file mode 100644 index 000000000..9fe6c30b7 --- /dev/null +++ b/changelog/unreleased/tmp.md @@ -0,0 +1,3 @@ +- `emergent_testing`: the browser page runs the same proof traversal as `fjs t` + instead of its own copy, through a new browser effect interpreter. Its + per-proof batching is gone, so both runners now schedule identically diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs new file mode 100644 index 000000000..c1dfe4cf0 --- /dev/null +++ b/fjs/effects/browser/module.mjs @@ -0,0 +1,105 @@ +/** + * A browser interpreter for the host-independent operations. + * + * It implements exactly three — `sandbox`, which calls a function and reports + * what happened instead of throwing; `catch`, which does the same for a pure + * thunk with no clock; and `all`, which performs its children concurrently. + * None of them names a browser API beyond `performance.now` and `Promise`: + * these are the same operations `effects/node` performs, and this module is the + * "follow the example" reading of its interpreter rather than a second design. + * + * **Three is the whole set, and that is a measurement rather than a starting + * point.** The shared proof traversal (`emergent_testing/module.f.mjs`) + * performs `sandbox`, `catch`, `all`, and whatever its reporter performs. + * `await` belongs to the *registration* path, which external frameworks drive + * and this one does not; a page loads its modules through its own importer + * rather than through an `import` operation; and a browser run measures its own + * wall clock rather than dispatching `now`. So `await`, `import`, `fetch` and + * `now` have no second implementer here — which is the fact + * `emergent_testing/todo/share-browser-console-runner.md` step 4 was waiting to + * learn before moving anything out of `effects/node`. + * + * The module has no Node dependencies: a page imports it directly as an ES + * module. + * + * @module + * + * @import { Operation, OperationMap } from '../types.ts' + * @import { Result } from '../../types/result/types.ts' + */ + +import { asyncRun } from '../module.mjs' +import { error, ok } from '../../types/result/module.f.mjs' + +/** + * Calls `f` and answers what happened — its value, or the value it threw — + * together with how long it took. + * + * This is the boundary that keeps a host value out of the pure traversal: the + * `try`/`catch` and the clock live here, and the core receives a + * `SandboxResult` it can read without knowing which host produced it. + * + * A returned promise is awaited and the clock read again after it settles, so + * an asynchronous leaf is timed by what it did rather than by how quickly it + * handed back a promise. Authored FunctionalScript has no promises, so this is + * a guard rather than a path anything is expected to take — the same one + * `effects/node` keeps, spelled the same way, because two runners that + * disagreed about an awaited leaf would not be one runner. + * + * @template T + * @param {() => T} f + * @returns {Promise<{ readonly result: Result, readonly duration: number }>} + */ +const sandbox = async f => { + /** @type {Result} */ + let result + let after + const before = performance.now() + try { + let p = f() + after = performance.now() + if (p instanceof Promise) { + p = await p + after = performance.now() + } + result = ok(p) + } catch (e) { + after = performance.now() + result = error(e) + } + return { result, duration: after - before } +} + +/** + * A browser effect runner for `sandbox`, `catch` and `all`, plus whatever + * `extra` operations the application adds — a page's own reporting, typically. + * + * `all` starts every child before awaiting any, which is a contract rather than + * an implementation detail: a child may wait on something a later sibling + * produces, so an interpreter that awaited one before starting the next would + * hang a graph the node runner completes. It answers in argument order however + * the children interleave, which is what lets the shared traversal report in + * structural order. + * + * @type {(extra: Partial>) => (effect: unknown) => Promise} + */ +export const browserRun = extra => { + /** @type {(effect: any) => Promise} */ + const run = asyncRun(/** @type {any} */ ({ + all: async (/** @type {readonly any[]} */ ...effects) => + ok(await Promise.all(effects.map(run))), + sandbox: async (/** @type {() => unknown} */ f) => ok(await sandbox(f)), + // No clock and no fixture convention — see `Catch` in + // `../node/types.ts` for why this is a second operation beside + // `sandbox` rather than a use of it. + catch: async (/** @type {() => unknown} */ f) => { + try { + return ok(ok(f())) + } catch (e) { + return ok(error(e)) + } + }, + ...extra, + })) + return run +} diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index 81acbb6f2..09e6284e8 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -55,7 +55,7 @@ provides*. Proposed destinations: | `fjs/effects/console/module.f.mjs` | `Read`, `Write`, `ReadConsoles`, `WriteConsoles`, `Console`, `log`, `error`, `readLine`, `errorExit`, and a **new named `Std`** (see below) | | `fjs/effects/test/module.f.mjs` | `Test`, `TestFn`, `TestContext`, `test` — registration with an external framework, not I/O | | stays in `fjs/effects/node` | `Fs` and its members, `Http`, `Forever`, `RandomInt`, `isNotFound`, `Env`, `Engine`, `NodeOp`, `NodeProgramOptions`, `Program`, `NodeProgram`, `NodeOperationMap` | -| unsettled | `Now`, `Fetch`, `Import` — this issue and share-browser-console-runner's step 4 disagree; step 5 decides (see the judgement call below) | +| stays, now settled | `Now`, `Fetch`, `Import` — the browser interpreter implements none of them, so none has a second implementer (see the judgement call below) | | already moved to `fjs/effects` | `OpResult`, `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult`, `ioError`, `toIoError` — the vocabulary every operation is declared in; `effects/node` re-exports them (see the judgement call below) | `NodeOp` stays where it is and keeps unioning every family — it is the @@ -69,23 +69,30 @@ Judgement calls worth deciding explicitly rather than by accident: - **`RandomInt` stays.** An ambient host capability with no cross-runtime abstraction to gain and no consumer outside `fjs/cas` and the interpreters. Moving it would be motion without a reader benefit. -- **`Now`, `Fetch` and `Import` are unsettled, and step 5 of +- **`Now`, `Fetch` and `Import` stay, and this was settled by building the + browser interpreter rather than by arguing.** This issue and [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) - decides them.** This issue put all three in the "stays" row on the reader-benefit - argument above; that issue's step 4 lists `now`, `fetch` and `import` among the - operations to move. Both were written without the fact that settles it — **which - operations a browser interpreter actually implements** — so neither ruling is - authoritative and the disagreement is recorded here rather than resolved by - whichever file a later reader opens first. - - The test to apply is the one `isNotFound` failed: not "does a browser also - have one of these", but "is this operation about no host in particular". By - that test `now` and `import` look likely to move — a browser proof run needs a - clock and dynamic import, so step 5 gives them a second implementer — and - `fetch` looks likely to stay, since nothing in the shared runner performs one - and DESIGN.md §4 extracts at the second *real* consumer, not the second - possible one. Those are expectations, not rulings: whichever way step 5 goes, - it updates both files in the same change. + step 4 disagreed: this file put all three in "stays" on the reader-benefit + argument, that one listed them among the operations to move. Neither was + written knowing the fact that decides it — which operations a browser + interpreter actually implements — so both recorded the disagreement and left + it to step 5. + + Step 5's answer is `sandbox`, `catch` and `all`, and nothing else. + `fjs/effects/browser/module.mjs` implements those three because the shared + proof traversal performs those three; a page loads its modules through its + own importer rather than an `import` operation, measures its own wall clock + rather than dispatching `now`, and performs no `fetch` at all. So none of the + three gained a second implementer, and DESIGN.md §4 keeps them here until one + does. + + Worth recording, because the earlier expectation written here was wrong about + two of them: "a browser proof run needs a clock and dynamic import" is true of + the *page* and false of the *effect set* — the page does both directly, in the + impure shell where host values belong, which is exactly the boundary this + whole exercise is drawing. Reasoning from what a host can do predicted the + wrong answer; reading what the interpreter had to implement gave the right + one. - **`isNotFound` stays, and this was tested.** It encodes `ENOENT` specifically — a POSIX filesystem code that a host without a filesystem never reports — so it *is* a Node-layer concern. A change that moved it to the core diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 1963cc7e3..d2528655b 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -15,12 +15,16 @@ * * @module * - * @import { BrowserTestReport, TestResult, _BrowserImporter, _BrowserTestResult, _TestAndPath } from './types.ts' + * @import { BrowserTestReport, Reporter, TestResult, _BrowserImporter, _BrowserReport, _BrowserTestResult } from './types.ts' + * @import { Func } from '../effects/types.ts' + * @import { Sandbox, SandboxResult } from '../effects/node/types.ts' * @import { Result } from '../types/result/types.ts' */ -import { addResult, collectTests, testResult, zeroTotals } from './module.f.mjs' -import { error as errorResult, invert, ok } from '../types/result/module.f.mjs' +import { addResult, collectTests, defaultTest, runModuleMap, zeroTotals } from './module.f.mjs' +import { browserRun } from '../effects/browser/module.mjs' +import { do_, pureOk } from '../effects/module.f.mjs' +import { ok } from '../types/result/module.f.mjs' /** @type {(value: unknown) => string} */ const text = value => { @@ -79,110 +83,51 @@ const moduleFailure = (source, duration, message, stack) => ({ module: source, path: '', name: source, status: 'failed', duration, message, stack, }) -/** @type {(module: string, path: readonly (string | null)[], throws: boolean, fn: () => unknown, result: (result: _BrowserTestResult) => void) => Promise} */ -const runOne = (module, path, throws, fn, result) => { - const start = performance.now() - // The throw expectation is applied with the same `invert` the console - // runner's `defaultTest` uses, and the status is then read off the result by - // the same `testResult`. Both runners therefore answer "did this leaf pass" - // in one place — the rule that used to be spelled out at four sites here and - // once again over there. - /** @type {(o: Result, duration: number) => TestResult} */ - const leaf = (o, duration) => - testResult(module, path, { result: throws ? invert(o) : o, duration }) - /** @type {(value: unknown) => Promise | readonly _BrowserTestResult[]} */ - const passed = value => { - const duration = performance.now() - start - if (throws) { - const failure = { ...leaf(ok(value), duration), - message: 'Expected the proof to throw', stack: '' } - result(failure) - return [failure] - } - // Reading the returned tree runs user code: an enumerable getter - // or a proxy trap can throw. That is a failure of the test that - // produced the value, never of the run — a rejected run leaves the - // page in `running` with no report and no completion event. - /** @type {readonly _TestAndPath[]} */ - let children - try { - children = collectTests([...path, null], false, value) - } catch (error) { - return failed(error) - } - return Promise.all(children.map(([childPath, child]) => - runOne(module, childPath, child.throws, child.fn, result) - )).then(results => { - const success = leaf(ok(value), duration) - result(success) - return [success, ...results.flat()] - }) - } - /** @type {(error: unknown) => readonly _BrowserTestResult[]} */ - const failed = error => { - const duration = performance.now() - start - if (throws) { - const success = leaf(errorResult(error), duration) - result(success) - return [success] - } - const [message, stack] = errorDetails(error) - const failure = { ...leaf(errorResult(error), duration), message, stack } - result(failure) - return [failure] - } - // `instanceof Promise`, then `await` — the whole of `fjs t`'s promise - // handling, spelled the same way here. - // - // It is deliberately not more than that. A promise can replace its own - // `then`, present a `constructor` that is not the intrinsic `Promise`, or - // carry a `Symbol.species` that fails, and each of those defeats `await` in - // a different way. Defending against them takes about 150 lines, none of - // which authored FunctionalScript can reach: it has no `Promise`, no - // `class`, no `Proxy` and no `Symbol`. `todo/imports-promises-realms.md` - // records each case, what the deleted machinery did about it, and what a - // runner does without it — to be implemented when an input that needs it - // actually exists. - // - // The value is wrapped in a tuple first so that resolving it cannot - // assimilate a proof tree carrying a `then` key: such a tree is a sub-tree - // with a test called `then` in it, in both runners. - // - // What makes this enough is the `await` above, not an assumption about the - // values that reach it. FunctionalScript as specified has no promises, and - // the browser suite selects `.f.mjs` — but that selection is by filename - // with no content check (`website/browser-prepare.mjs`), so a module that - // does not conform is still loaded and can return one. The handling here is - // correct either way. See `todo/imports-promises-realms.md` for the - // machinery this replaces and the measurements behind removing it. - /** @type {(value: unknown) => Promise | readonly _BrowserTestResult[]} */ - const settled = async value => { - // Even the brand check runs user code: `instanceof` consults - // `getPrototypeOf`, which a proxy can trap and a revoked one always - // throws from. `fjs t` performs this check inside `sandbox`'s - // `try`/`catch`, so it reports such a value as its test's failure; this - // handler has no enclosing `try`, so without one here the whole run - // rejects and the page never leaves `running`. - let isPromise = false - try { - isPromise = value instanceof Promise - } catch (error) { - return failed(error) - } - if (!isPromise) { return passed(value) } - /** @type {readonly [unknown]} */ - let resolved - // Only the `await` is guarded. A throw from `passed` is the traversal's - // own and has its own handling; catching it here would report a broken - // proof tree as a rejected promise. - try { - resolved = [await value] - } catch (error) { - return failed(error) - } - return passed(resolved[0]) +/** + * The `report` operation's constructor: hand one leaf record to the page. + * + * @type {Func<_BrowserReport>} + */ +const report = do_('report') + +/** + * The page's leaf record, built from what the shared traversal decided. + * + * `t` arrives already decided — identity, status and duration all come from + * `testResult` inside the traversal — so the only thing left here is the part + * `TestResult` deliberately leaves to each host: how to *describe* a failure. + * A passing leaf needs no description; a failing one is described from the + * value, except for the one case a value cannot describe, where a proof marked + * `throw` returned instead of throwing and the failure is the absence of a + * throw rather than anything thrown. + * + * @type {(t: TestResult, r: SandboxResult, throws: boolean) => _BrowserTestResult} + */ +const browserResult = (t, r, throws) => { + if (t.status === 'passed') { return t } + if (throws) { + return { ...t, message: 'Expected the proof to throw', stack: '' } } - return Promise.resolve().then(() => [fn()]).then(([value]) => settled(value), failed) + const [message, stack] = errorDetails(r.result[1]) + return { ...t, message, stack } +} + +/** + * The page's half of the shared runner. + * + * `test` is `defaultTest` — the same sandboxing and the same `invert` `fjs t` + * uses — so "did this leaf pass" is not decided here at all. `result` builds + * the page's record and hands it to the `report` operation, whose value the + * traversal keeps in structural order. `summary` has nothing to do: the page + * renders its report from the outcome it is handed, rather than from an event + * telling it the run ended. + * + * @type {Reporter} + */ +const browserReporter = { + test: defaultTest, + result: (t, r, throws) => report(browserResult(t, r, throws)), + summary: () => pureOk(undefined), } /** @@ -231,36 +176,64 @@ export const runBrowserProofs = (modules, result = () => undefined) => { // The result stays in the report the run resolves with. } } - /** @type {(module: string, error: unknown) => () => Promise} */ - const unreadable = (module, error) => () => { - const [message, stack] = errorDetails(error) - const failure = moduleFailure(module, 0, message, stack) - announce(failure) - return Promise.resolve([failure]) - } - const tests = modules.flatMap(([module, proof]) => { - // Reading an exported tree runs user code just as reading a returned - // one does. A module that cannot be enumerated is one failed module, - // never a run that ends without a report. + // Reading a module's exported tree runs user code, and the shared traversal + // deliberately does not guard that one: there is no leaf to attribute it + // to, so `fjs t` panics and the page does this instead. A module that + // cannot be enumerated is one failed module, never a run that ends without + // a report. See `todo/hostile-proof-values.md`. + /** @type {readonly (readonly [string, unknown] | _BrowserTestResult)[]} */ + const prepared = modules.map(([module, proof]) => { try { - return collectTests([], false, proof).map(([path, entry]) => - () => runOne(module, path, entry.throws, entry.fn, announce) - ) + collectTests([], false, proof) + return /** @type {const} */ ([module, proof]) } catch (error) { - return [unreadable(module, error)] + const [message, stack] = errorDetails(error) + return moduleFailure(module, 0, message, stack) } }) - const batchSize = 25 - /** @type {(index: number, results: readonly _BrowserTestResult[]) => Promise} */ - const runBatch = (index, results) => { - const batch = tests.slice(index, index + batchSize) - if (batch.length === 0) { return Promise.resolve(results) } - return Promise.all(batch.map(test => test())).then(next => - new Promise(resolve => setTimeout(resolve, 0, [...results, ...next.flat()])) - ).then(next => runBatch(index + batchSize, next)) - } - const completed = runBatch(0, []) - return completed.then(results => reportOf(performance.now() - start, results)) + /** @type {Record} */ + const moduleMap = Object.fromEntries(prepared.flatMap( + e => e instanceof Array ? [[e[0], { proof: e[1] }]] : [])) + const run = browserRun(/** @type {any} */ ({ + // The page's end of the `report` operation: render as it lands, and + // answer the record back so the traversal can keep it in order. + report: async (/** @type {_BrowserTestResult} */ r) => { + announce(r) + return ok(r) + }, + })) + return run(runModuleMap(browserReporter)(moduleMap)).then(answer => { + /** @type {Result<{ readonly results: readonly _BrowserTestResult[] }, unknown>} */ + const outcome = /** @type {any} */ (answer) + // A failure here is the *runner* failing, not a proof: the traversal + // answers `ok` for every proof outcome, so the error channel carries + // only a dispatch failure — an operation this interpreter does not + // implement. Reporting it as the run's own failure keeps the page out + // of `running` forever, which is the one outcome a page must never + // reach. + if (outcome[0] === 'error') { + const [message, stack] = errorDetails(outcome[1]) + const failure = moduleFailure('', performance.now() - start, message, stack) + announce(failure) + return reportOf(performance.now() - start, [failure], 'infrastructure-error') + } + // Each module's leaf records, back in the order the page was given its + // modules: a module that failed to enumerate contributes its own + // failure at the position it was passed in, and a readable one + // contributes what the traversal produced for it. + const byModule = new Map() + for (const r of outcome[1].results) { + const list = byModule.get(r.module) + if (list === undefined) { byModule.set(r.module, [r]) } else { list.push(r) } + } + const results = prepared.flatMap(e => + e instanceof Array ? byModule.get(e[0]) ?? [] : [e]) + // A module failure never reaches `report`, so it is announced here. + for (const e of prepared) { + if (!(e instanceof Array)) { announce(e) } + } + return reportOf(performance.now() - start, results) + }) } /** @type {(root: Element) => (Window & { fjsBrowserTestReport?: Promise }) | null} */ diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index 58b822cd3..05c42df9d 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -2,8 +2,8 @@ * Test-framework helpers for running and reporting FunctionalScript tests. * * Two parallel execution paths: - * - `runModule` / `Reporter` — self-hosted Effects runner used by `fjs t`; - * sandboxes each leaf call individually and accumulates `RunTotals`. + * - `runModule` / `Reporter` — self-hosted Effects runner used by `fjs t`; + * sandboxes each leaf call individually and accumulates a `RunOutcome`. * - `registerModule` / `TestContext` — registers tests with an external * framework (Node `--test`, Bun, Deno) at import time; the framework owns * scheduling and pass/fail counting. @@ -14,7 +14,7 @@ * @import { Result } from '../types/result/types.ts' * @import { Effect, NotImplemented } from '../effects/types.ts' * @import { LoadModuleOperations, ModuleMap } from '../dev/types.ts' - * @import { TestFn, TestEntry, TestSet, Path, Reporter, RunTotals, TestResult, _TestAndPath } from './types.ts' + * @import { TestFn, TestEntry, TestSet, Path, Reporter, RunOutcome, RunTotals, TestResult, _TestAndPath } from './types.ts' * @import { All, Await, Catch, Env, IoChannel, NodeProgram, NodeProgramOptions, Program, Sandbox, SandboxResult, Test, TestContext, Write, WriteConsoles } from '../effects/node/types.ts' */ @@ -182,13 +182,33 @@ export const registerModule = (ctx, k, v, star) => { const mergeTotals = (a, b) => ({ passed: a.passed + b.passed, failed: a.failed + b.failed, duration: a.duration + b.duration }) +/** + * The empty {@link RunOutcome}. + * + * @type {RunOutcome} + */ +const zeroOutcome = { totals: zeroTotals, results: [] } + +/** + * Joins two outcomes, keeping the leaf records in the order the walk produced + * them — which is what makes a host's report ordered by structure rather than + * by which leaf settled first. + * + * @type {(a: RunOutcome, b: RunOutcome) => RunOutcome} + */ +const mergeOutcome = (a, b) => ({ + totals: mergeTotals(a.totals, b.totals), + results: [...a.results, ...b.results], +}) + /** * @template {Operation} O - * @param {Reporter} reporter - * @returns {(k: string, v: unknown) => (ts: RunTotals) => Effect} + * @template R + * @param {Reporter} reporter + * @returns {(k: string, v: unknown) => Effect, IoChannel>} */ -const runModule = ({ result, test }) => (k, v) => ts => { - /** @type {(entry: _TestAndPath) => Effect} */ +const runModule = ({ result, test }) => (k, v) => { + /** @type {(entry: _TestAndPath) => Effect, IoChannel>} */ const one = ([testPath, set]) => { // The leaf's shared record is built here, next to the sandbox result it // is read from, so the leaf-landed event carries the value already @@ -233,25 +253,30 @@ const runModule = ({ result, test }) => (k, v) => ts => { ([t, sr]) => result(t, sr, set.throws)) return step( reported, - ([, [t, sr, children]]) => { - const total = addResult(zeroTotals, t) + ([r, [t, , children]]) => { + /** @type {RunOutcome} */ + const self = { totals: addResult(zeroTotals, t), results: [r] } if (children.length === 0) { - return pureOk(total) + return pureOk(self) } + // The leaf's own record goes first, so a parent precedes the + // children its return value produced. return mapStep( walkEntries(children), - sub => mergeTotals(total, sub)) + sub => mergeOutcome(self, sub)) }) } - /** @type {(entries: readonly _TestAndPath[]) => Effect} */ + /** @type {(entries: readonly _TestAndPath[]) => Effect, IoChannel>} */ const walkEntries = entries => - mapStep(allOk(...entries.map(one)), states => states.reduce(mergeTotals, zeroTotals)) + // `allOk` answers in argument order however the effects interleave, so + // siblings stay in declaration order even though they run concurrently. + mapStep(allOk(...entries.map(one)), states => states.reduce(mergeOutcome, zeroOutcome)) // The *module's* own export is read unguarded, and that asymmetry is // deliberate rather than an oversight: there is no leaf to attribute it to, // so an unreadable `proof` export is whatever loaded the module's problem. // `fjs t` panics on one; the browser page catches it and reports one failed // module. See `todo/hostile-proof-values.md`. - return mapStep(walkEntries(collectTests([], false, v)), delta => mergeTotals(ts, delta)) + return walkEntries(collectTests([], false, v)) } /** @type {(moduleMap: ModuleMap) => readonly (readonly [string, unknown])[]} */ @@ -265,24 +290,32 @@ const proofEntries = moduleMap => * 1 = at least one failure). * * @template {Operation} O - * @param {Reporter} reporter - * @returns {(moduleMap: ModuleMap) => Effect} + * @template R + * @param {Reporter} reporter + * @returns {(moduleMap: ModuleMap) => Effect, IoChannel>} */ export const runModuleMap = reporter => moduleMap => { const { summary } = reporter const modules = proofEntries(moduleMap) const total = mapStep( - allOk(...modules.map(([k, v]) => runModule(reporter)(k, v)(zeroTotals))), - m => m.reduce(mergeTotals, zeroTotals)) - // The totals are still needed after the summary has been printed, so they - // are carried forward in a history rather than closed over by a nested + allOk(...modules.map(([k, v]) => runModule(reporter)(k, v))), + m => m.reduce(mergeOutcome, zeroOutcome)) + // The outcome is still needed after the summary has been printed, so it is + // carried forward in a history rather than closed over by a nested // continuation. const reported = historyStep( history(total), - summary) - return mapStep(reported, ([, ts]) => ts.failed !== 0 ? 1 : 0) + o => summary(o.totals)) + return mapStep(reported, ([, o]) => o) } +/** + * The exit code a run's outcome means: `1` when any leaf failed. + * + * @type {(o: RunOutcome) => number} + */ +export const exitCodeOf = o => o.totals.failed !== 0 ? 1 : 0 + /** * Ends a run with the exit code it computed, reporting a channel failure on * `stderr` as exit `1`. @@ -318,11 +351,14 @@ const exitCodeStep = e => * reason on `stderr` instead of unwinding as a panic. * * @template {Operation} O - * @param {Reporter} reporter + * @template R + * @param {Reporter} reporter * @returns {Program} */ export const testAll = reporter => options => - exitCodeStep(step(loadModuleMap(options.env), runModuleMap(reporter))) + exitCodeStep(mapStep( + step(loadModuleMap(options.env), runModuleMap(reporter)), + exitCodeOf)) /** * Registers all modules in `moduleMap` that export a `proof` property with @@ -443,7 +479,7 @@ const fmtResultLine = ({ name, duration }, color, label) => * annotations instead of colored lines. Exported as a factory so the * GitHub format path can be exercised directly from tests. * - * @type {(options: NodeProgramOptions) => Reporter} + * @type {(options: NodeProgramOptions) => Reporter} */ export const defaultReporter = options => { const write = csiWrite(options) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index a7caf9d9f..e0c921001 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -125,10 +125,22 @@ and is reviewable without the next one. in [imports, promises and realms](imports-promises-realms.md); the scope rule they rest on is in [browser testing](browser-testing.md). -- [ ] **4. Common effects.** Move the host-independent operations (`all`, - `await`, `sandbox`, and whichever of `now`, `fetch` and `import` survive - the test below) out of `effects/node` into a shared module that - `effects/node` re-exports unchanged, so nothing has to move with them. +- [ ] **4. Common effects.** Move `all`, `sandbox` and `catch` out of + `effects/node` into a shared module that `effects/node` re-exports + unchanged, so nothing has to move with them. + + **The list is now settled, by measurement rather than by argument.** + Step 5's interpreter implements exactly those three, so exactly those + three have a second implementer. `await` does not: it belongs to the + *registration* path that external frameworks drive, which no browser + runs. `import` does not: a page loads modules through its own importer. + `now` does not: a browser run measures its own wall clock rather than + dispatching an operation for it. `fetch` does not: nothing in the shared + runner performs one. Those four stay in `effects/node` until something + gives them a second implementer, which is the same rule that let this + list shrink rather than a different one applied to them. + [node-module-layering](../../effects/todo/node-module-layering.md) + carries the same answer. **Three of that list are unsettled, and this step does not get to assume them.** `all`, `await` and `sandbox` are agreed: @@ -175,21 +187,12 @@ and is reviewable without the next one. and this took it. `fjs t` gained the behaviour in the process, which is what made that change worth landing on its own rather than inside the port. -- [ ] **5. A browser interpreter** for exactly those operations, with no - scheduling policy of its own. This is also what earns step 4's *operation* - move its second consumer: until a second host implements `sandbox`, - `await` and `all`, moving them out of `effects/node` makes nothing shorter - or clearer, and DESIGN.md §4 says to extract at the second real consumer - rather than before it. The two are therefore one design in two commits, - not one step deferred. - - **Its operation set is also the ruling** on `now`, `fetch` and `import`, - which step 4 and - [node-module-layering](../../effects/todo/node-module-layering.md) - currently disagree about. What this interpreter implements is what has a - second consumer; what it does not implement stays in `effects/node` until - something needs it. Write the answer into both files in this step's own - change, so neither is left asserting what the other denies. +- [x] **5. A browser interpreter** for exactly those operations, with no + scheduling policy of its own. `fjs/effects/browser/module.mjs`: + `sandbox`, `catch`, `all`, plus whatever operations the application adds + — for the page, one `report`. `sandbox` is `effects/node`'s, copied + rather than redesigned, because two runners that disagreed about an + awaited leaf would not be one runner. - [x] **6. One reporter.** The event stream — a leaf landed, a run ended — that both hosts subscribe to. Step 2 gave them the *value*; this gave them the seam it travels through. `Reporter.result` now receives the @@ -207,12 +210,32 @@ and is reviewable without the next one. fold's summed durations, because its leaves run concurrently and the sum only means "how long the run took" for a sequential runner — `RunTotals` documents that. -- [ ] **7. One skeleton.** The page's proof-tree walk is deleted and the shared - traversal runs it. The walk's `batchSize = 25` batching goes onto the - table with it: that is a scheduling policy of the page's own — the same - kind the reverted attempt was faulted for inventing, though this one - predates it in `browser.mjs` — and step 7 is where it gets decided - rather than silently inherited. +- [x] **7. One skeleton.** The page's proof-tree walk is deleted and the shared + traversal runs it. `browser.mjs` no longer discovers leaves, applies the + throw expectation, walks return values or counts anything: it supplies a + `Reporter` and an interpreter, and the traversal does the rest. + + **The batching went with it**, as this file said it should be decided + rather than inherited: `batchSize = 25` and its `setTimeout` yield are + gone, and the browser now schedules exactly as `fjs t` does. Nothing + asked for the batching, no measurement motivated the constant, and it was + the origin of six rounds of review in the reverted attempt. + + **What the skeleton had to grow**, rather than what the browser had to + keep: the traversal now threads a `RunOutcome` — the folded totals + plus each host's own leaf records, in the walk's order. The browser needs + its report's `results` ordered by structure, and taking them in + completion order would have pinned the scheduler's behaviour instead of + the suite's. `fjs t` answers `void` there and collects nothing, which is + the extension point doing its job. + + **What stayed the page's own, with the reason:** reading a *module's* + exported tree. The shared walk guards a returned tree through `catch` + (see [hostile proof values](hostile-proof-values.md)) but deliberately + not the exported one, because there is no leaf to attribute that failure + to. `fjs t` panics; the page catches it and reports one failed module. + That asymmetry predates this step and survives it. + - [ ] **8. The layout move**, and the website preparation program. Steps 3 and 7 are the ones that change behaviour, so they are the ones to keep diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index 5191ae631..bbe43877e 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -5,7 +5,7 @@ */ import type { Effect, Operation } from '../effects/types.ts' -import type { IoChannel, SandboxResult } from '../effects/node/types.ts' +import type { IoChannel, OpResult, SandboxResult } from '../effects/node/types.ts' /** A zero-argument test function whose return value may contain sub-tests. */ export type TestFn = () => unknown @@ -127,6 +127,21 @@ export type BrowserTestReport = { readonly results: readonly _BrowserTestResult[] } +/** + * The browser page's own reporting operation: the shared traversal hands it one + * leaf record, and the page's interpreter renders it and answers it back. + * + * It is an operation rather than a callback because the traversal is pure — + * rendering a row is a side effect, and the effect system is where those go. + * One operation for the whole event is enough: making each DOM detail its own + * operation would grow the browser's op-set without making the shared API any + * better. + * + * @internal + */ +export type _BrowserReport = + readonly['report', (r: _BrowserTestResult) => OpResult<_BrowserTestResult>] + /** * Loads one proof module by its source path for the browser runner. * @@ -178,7 +193,7 @@ export type RunTotals = { * tail that reports it — free of a parameter every caller would have to thread * through unchanged. */ -export type Reporter = { +export type Reporter = { /** * A leaf landed. The first argument is the shared {@link TestResult} — the * runner builds it with `testResult` before notifying, so a reporter @@ -186,12 +201,37 @@ export type Reporter = { * The raw `SandboxResult` and the throw expectation travel with it because * describing a *thrown value* is each host's part (see {@link TestResult}), * and the description needs the value. + * + * **It answers `R`, the host's own record of the leaf**, and the traversal + * keeps those in {@link RunOutcome}. That is how a host gets its results in + * *structural* order — a parent before the children its return value + * produced, siblings in declaration order — rather than in the order they + * happened to finish. The distinction is not academic: leaves run + * concurrently, so completion order belongs to the scheduler, and a report + * built from it would be pinning an engine's behaviour rather than the + * suite's. + * + * `fjs t` answers `void`, having already written its line by the time it + * returns; the browser answers the record its wire report is built from. */ - readonly result: (t: TestResult, r: SandboxResult, throws: boolean) => Effect + readonly result: (t: TestResult, r: SandboxResult, throws: boolean) => Effect /** The run ended, with the totals folded from every leaf that landed. */ readonly summary: (totals: RunTotals) => Effect readonly test: (file: string, path: Path, set: TestEntry) => Effect, IoChannel> } +/** + * What a run produced: its folded {@link RunTotals}, and every leaf record the + * reporter answered, in the traversal's own order. + * + * The two are not redundant. The totals are a fold and cannot be rebuilt from a + * list a host chose to leave empty (`fjs t` collects `void`), and the list is + * ordered by the walk rather than by when each leaf settled. + */ +export type RunOutcome = { + readonly totals: RunTotals + readonly results: readonly R[] +} + /** @internal */ export type _TestAndPath = readonly [Path, TestEntry] From 6f0f7219620c6c8840e807291852eeff1590c5ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 18:53:13 +0000 Subject: [PATCH 209/370] changelog: name the entry by its real PR number Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/{tmp.md => 1759.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/unreleased/{tmp.md => 1759.md} (100%) diff --git a/changelog/unreleased/tmp.md b/changelog/unreleased/1759.md similarity index 100% rename from changelog/unreleased/tmp.md rename to changelog/unreleased/1759.md From d54d63c801d9aea8d9d77ceed00b4df3ad0a3a49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 18:54:30 +0000 Subject: [PATCH 210/370] todo: file the EDAG flat-example re-notation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1756 corrected the places `edag-stage1-discussion.md` is read as normative — the structural-operations table, subject 4's resolution and history, the object-constructor validation rule — after review found the design specifying flat `["[]", ...node]` where the schema nests. Its worked examples were left, because they do not all want the same treatment and deciding per site is a judgement rather than a sweep. That reasoning lived only in the review thread, which REVIEW.md names as the one place an answer does not survive a merge; this is it written down. The inventory splits three ways. About a dozen examples describe today's shape and are mechanical — including every `["{}"]`, which the schema writes `["{}", []]`; one sharing pair recurs in four sections and should read the same in all four. Three passages quote a superseded proposal, where the flat spelling is what the quote said and rewriting it would falsify the record rather than correct it — though one of those is a decision record also read as current, and one is an objection the nested form happens to answer. The remaining prose ellipses (`["[]", ...]` for "the array node") make no claim about operand grouping. P4: the normative table is correct, `fjs/edag/README.md` is correct, and no code is wrong — the risk is a reader copying a worked example instead of the table. That is why it is filed rather than swept. Sites are named by section, not line number, per tokenizer-line-citations. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- todo/edag-stage1-flat-examples.md | 92 +++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 todo/edag-stage1-flat-examples.md diff --git a/todo/edag-stage1-flat-examples.md b/todo/edag-stage1-flat-examples.md new file mode 100644 index 000000000..0a076aeef --- /dev/null +++ b/todo/edag-stage1-flat-examples.md @@ -0,0 +1,92 @@ +## edag-stage1-flat-examples. Re-notate the flat constructor examples + +**Priority:** P4 +**Status:** open + +### Problem + +[`edag-stage1-discussion.md`](./edag-stage1-discussion.md) states both +structural constructors in the nested form its normative parts now use — +`["[]", [...node]]` and `["{}", [...entry]]`, matching the schema in +[`fjs/edag/module.f.mjs`](../fjs/edag/module.f.mjs) and the form column in +[`fjs/edag/README.md`](../fjs/edag/README.md) — but roughly a dozen of its +worked examples still spell the operands flat, as `["[]", x, x]`. The empty +object appears throughout as the one-element `["{}"]`, which the schema +writes `["{}", []]`. + +The normative places were corrected in +[#1756](https://github.com/functionalscript/functionalscript/pull/1756) after +review found the design and the schema disagreeing: the structural-operations +table, subject 4's resolution and history, the object-constructor validation +rule, and the three forms in +[`compile-modules-to-edag.md`](../fjs/djs/todo/compile-modules-to-edag.md). +The examples were left because they do not all want the same treatment, and +deciding per site is a judgement about what each passage is for — which is +this issue. + +The risk is mild but real: someone reading a worked example rather than the +table copies a shape validation rejects. Nothing is wrong in the code, and +`fjs/edag/README.md` is correct throughout, which is why this is P4 rather +than the P2 the original finding carried. + +### Proposal + +Three classes, and only the first is mechanical. + +**1. Re-notate — examples describing today's shape.** Rewrite these to the +nested form, including `["{}"]` → `["{}", []]`: + +|Section|What it shows| +|-|-| +|Baseline: an expression DAG with anchored evaluation|`["[]", x, x]` vs `["[]", ["{}"], ["{}"]]`, and the `export default` code block below it| +|The core invariant|the same sharing pair; `["()", f, ["[]", a, b]]`| +|Other operations|the `["=>", ["[]", ["self"]], …]` and `["()", …, ["[]", …]]` frame examples| +|4. Object constructor: ordered entries|the integer-key ordering caveat, `["{}", [":", "2", a], [":", "1", b]]`| +|9. Canonical graph serialization and hashing|the sharing pair again| +|10. Free variables|`["()", ["self"], ["[]"]]`| +|12. `toString(f)`|the sharing pair again| + +The sharing pair (`["[]", x, x]` against `["[]", ["{}"], ["{}"]]`) recurs in +four sections and should read identically in all four. + +**2. Leave, or annotate — passages quoting a superseded proposal.** Rewriting +these falsifies the record rather than correcting it, because the flat +spelling is what the quoted proposal said: + +- *4. Object constructor* — "*Rejected: forbidding descriptor-array identity + reuse*" quotes an earlier draft's own `["{}", e, e]`. +- *6. Command vocabulary* — "the earlier objection — that `["[]", a, b]` would + read as both a two-element array and `a[b]`". Worth a note rather than a + rewrite: the nested form removes that ambiguity outright, which is an + argument for it the section does not yet make. +- *6. Command vocabulary* — "**Decided: the object constructor is `"{}"` with + ordered entries**" writes `["{}", [":", key, value], …]`. This one is a + decision record that is *also* read as current, so it likely does want the + nested form plus a note that only the operand grouping changed, not the + decision. + +**3. Leave alone — prose ellipses.** `["[]", ...]` and `["{}", …]` standing for +"the array node" in *The core invariant*, *Other operations*, *4. Object +constructor*, and *6. Command vocabulary* say nothing about operand grouping +and are not claims about shape. + +Sections are named rather than line numbers cited, per +[tokenizer-line-citations](../fjs/js/todo/tokenizer-line-citations.md). + +### Tasks + +- [ ] Re-notate the class-1 examples, including every `["{}"]` → `["{}", []]`. +- [ ] Decide each of the three class-2 passages: leave, annotate, or rewrite. +- [ ] Re-read the document for flat forms this inventory missed — it was built + by grepping `["[]"` and `["{}"`, which does not catch a constructor + written across a line break. +- [ ] `npx tsc`, `fjs test` — documentation only, but the repo's gate. + +### Related + +- [`../fjs/edag/README.md`](../fjs/edag/README.md) — "Why an array operand + rather than a variadic tail" gives the reason the shape is what it is. +- [`edag-stage1-discussion.md`](./edag-stage1-discussion.md) — the document + this corrects. +- [`edag-spec.md`](./edag-spec.md) — where subjects are distilled once decided; + it should not inherit the flat spelling. From 93171a7b45db67881f1043aa76c297e61f7b927e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:10:41 +0000 Subject: [PATCH 211/370] emergent_testing: read each browser proof export exactly once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page enumerated every export twice: once in a preliminary `collectTests` that only asked whether the tree could be read, and again inside the shared traversal. Enumerating is not idempotent — a getter in the export runs on every read — so a value that succeeded once and threw next escaped as a synchronous throw from `runBrowserProofs`, leaving the page in `running` with no report at all. The leaves collected by that one guarded read now go straight to `runEntries`, a new seam in the shared traversal for a host that enumerates its own modules. Because the page then no longer needs a `ModuleMap`, its modules stay the list they arrive as: two entries naming the same module are two runs in the order passed, where `Object.fromEntries` had kept only the last and repeated it for both. A module that will not enumerate is handed to the same `report` operation as a leaf, so it is rendered in the position it was passed in and the separate announcement pass is gone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/emergent_testing/browser.mjs | 63 ++++++++++++++------------ fjs/emergent_testing/browser/proof.mjs | 30 ++++++++++++ fjs/emergent_testing/module.f.mjs | 41 +++++++++++++---- 3 files changed, 96 insertions(+), 38 deletions(-) diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 24bec60d5..0adba2d5f 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -13,15 +13,16 @@ * iframe therefore renders into that frame, and a proof can drive the module * with a stand-in root. * - * @import { BrowserTestReport, Reporter, TestResult, _BrowserImporter, _BrowserReport, _BrowserTestResult } from './types.ts' - * @import { Func } from '../effects/types.ts' - * @import { Sandbox, SandboxResult } from '../effects/node/types.ts' + * @import { BrowserTestReport, Reporter, TestResult, _BrowserImporter, _BrowserReport, _BrowserTestResult, _TestAndPath } from './types.ts' + * @import { Effect, Func, IoChannel } from '../effects/types.ts' + * @import { All, Catch, Sandbox, SandboxResult } from '../effects/node/types.ts' * @import { Result } from '../types/result/types.ts' */ -import { addResult, collectTests, defaultTest, runModuleMap, zeroTotals } from './module.f.mjs' +import { addResult, collectTests, defaultTest, runEntries, zeroTotals } from './module.f.mjs' import { browserRun } from '../effects/browser/module.mjs' -import { do_, pureOk } from '../effects/module.f.mjs' +import { allOk } from '../effects/node/module.f.mjs' +import { do_, mapStep, pureOk } from '../effects/module.f.mjs' import { ok } from '../types/result/module.f.mjs' /** @type {(value: unknown) => string} */ @@ -179,19 +180,33 @@ export const runBrowserProofs = (modules, result = () => undefined) => { // to, so `fjs t` panics and the page does this instead. A module that // cannot be enumerated is one failed module, never a run that ends without // a report. See `todo/hostile-proof-values.md`. - /** @type {readonly (readonly [string, unknown] | _BrowserTestResult)[]} */ + // + // The export is read **once**, here, and the leaves go on to `runEntries`: + // enumerating is not idempotent, so a preliminary read that only checked + // whether the tree can be enumerated would run every getter in it a second + // time — and a getter that succeeds once and throws next would escape as a + // synchronous throw, leaving the page in `running` with no report at all. + /** @type {readonly (readonly ['ok', string, readonly _TestAndPath[]] | readonly ['failed', _BrowserTestResult])[]} */ const prepared = modules.map(([module, proof]) => { try { - collectTests([], false, proof) - return /** @type {const} */ ([module, proof]) + return /** @type {const} */ (['ok', module, collectTests([], false, proof)]) } catch (error) { const [message, stack] = errorDetails(error) - return moduleFailure(module, 0, message, stack) + return /** @type {const} */ (['failed', moduleFailure(module, 0, message, stack)]) } }) - /** @type {Record} */ - const moduleMap = Object.fromEntries(prepared.flatMap( - e => e instanceof Array ? [[e[0], { proof: e[1] }]] : [])) + // The page's modules are a *list*, and nothing stops it naming the same + // module twice: two entries with one label are two runs, in the order they + // were passed, so they are run as a list rather than folded into a map + // keyed by name. + /** @type {(e: (typeof prepared)[number]) => Effect} */ + const runOne = e => e[0] === 'ok' + ? mapStep(runEntries(browserReporter)(e[1], e[2]), o => o.results) + // A module failure has no leaf to be reported by, so it is handed to + // the same `report` operation directly: the page renders it as it + // lands, in the position the module was passed in, exactly like a leaf. + : mapStep(report(e[1]), r => /** @type {readonly _BrowserTestResult[]} */ ([r])) + const all = mapStep(allOk(...prepared.map(runOne)), lists => lists.flat()) const run = browserRun(/** @type {any} */ ({ // The page's end of the `report` operation: render as it lands, and // answer the record back so the traversal can keep it in order. @@ -200,8 +215,8 @@ export const runBrowserProofs = (modules, result = () => undefined) => { return ok(r) }, })) - return run(runModuleMap(browserReporter)(moduleMap)).then(answer => { - /** @type {Result<{ readonly results: readonly _BrowserTestResult[] }, unknown>} */ + return run(all).then(answer => { + /** @type {Result} */ const outcome = /** @type {any} */ (answer) // A failure here is the *runner* failing, not a proof: the traversal // answers `ok` for every proof outcome, so the error channel carries @@ -215,22 +230,10 @@ export const runBrowserProofs = (modules, result = () => undefined) => { announce(failure) return reportOf(performance.now() - start, [failure], 'infrastructure-error') } - // Each module's leaf records, back in the order the page was given its - // modules: a module that failed to enumerate contributes its own - // failure at the position it was passed in, and a readable one - // contributes what the traversal produced for it. - const byModule = new Map() - for (const r of outcome[1].results) { - const list = byModule.get(r.module) - if (list === undefined) { byModule.set(r.module, [r]) } else { list.push(r) } - } - const results = prepared.flatMap(e => - e instanceof Array ? byModule.get(e[0]) ?? [] : [e]) - // A module failure never reaches `report`, so it is announced here. - for (const e of prepared) { - if (!(e instanceof Array)) { announce(e) } - } - return reportOf(performance.now() - start, results) + // `allOk` answers in argument order, so the records are already in the + // order the page passed its modules in, with each module's leaves in + // structural order inside it. + return reportOf(performance.now() - start, outcome[1]) }) } diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 6b542ec61..8afc48c0f 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -354,6 +354,36 @@ export const proof = { assertStructurallySame([...p.states], ['running', 'failed']) assertEq(p.view.events.length, 1) }, + exportedTreeIsReadOnce: async () => { + // The export is enumerated exactly once. A getter that succeeds on the + // first read and throws on the next is not a module failure here — but + // it is proof that nothing reads the tree twice, and a second read + // would escape as a synchronous throw, leaving the page in `running`. + let reads = 0 + const proof = { + get t() { + reads += 1 + if (reads > 1) { throw new Error('second read') } + return () => undefined + }, + } + const report = await runBrowserProofs([['m', proof]]) + assertEq(reads, 1) + assertEq(report.status, 'passed') + assertEq(report.totals.passed, 1) + }, + // The page's modules are a list, not a map: nothing stops it naming the + // same module twice, and both entries are their own run. + repeatedModuleLabelsBothRun: async () => { + const report = await runBrowserProofs([ + ['m', { first: () => undefined }], + ['m', { second: () => undefined }], + ]) + assertEq(report.totals.tests, 2) + assertStructurallySame( + report.results.map(r => r.path), + ['.first', '.second']) + }, returnedTreeThrows: async () => { // Reading the returned tree runs user code. When it throws, the test // that produced the value fails and the page still reaches a terminal diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index 05c42df9d..6a2139964 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -202,12 +202,20 @@ const mergeOutcome = (a, b) => ({ }) /** + * Runs already-collected leaves under the module name `k`. + * + * This is the seam for a host that enumerates its own modules: the browser + * page reads each export inside its own `try`, because a module that will not + * enumerate is one failed module there rather than a dead run, and because its + * modules arrive as a *list* that may name the same module twice — neither of + * which a `ModuleMap` keyed by module name can express. + * * @template {Operation} O * @template R * @param {Reporter} reporter - * @returns {(k: string, v: unknown) => Effect, IoChannel>} + * @returns {(k: string, entries: readonly _TestAndPath[]) => Effect, IoChannel>} */ -const runModule = ({ result, test }) => (k, v) => { +export const runEntries = ({ result, test }) => (k, entries) => { /** @type {(entry: _TestAndPath) => Effect, IoChannel>} */ const one = ([testPath, set]) => { // The leaf's shared record is built here, next to the sandbox result it @@ -271,14 +279,31 @@ const runModule = ({ result, test }) => (k, v) => { // `allOk` answers in argument order however the effects interleave, so // siblings stay in declaration order even though they run concurrently. mapStep(allOk(...entries.map(one)), states => states.reduce(mergeOutcome, zeroOutcome)) - // The *module's* own export is read unguarded, and that asymmetry is - // deliberate rather than an oversight: there is no leaf to attribute it to, - // so an unreadable `proof` export is whatever loaded the module's problem. - // `fjs t` panics on one; the browser page catches it and reports one failed - // module. See `todo/hostile-proof-values.md`. - return walkEntries(collectTests([], false, v)) + return walkEntries(entries) } +/** + * Runs everything reachable from one module's `proof` export. + * + * The export is enumerated here, and **unguarded** — that asymmetry is + * deliberate rather than an oversight: there is no leaf to attribute the + * failure to, so an unreadable `proof` export is whatever loaded the module's + * problem. `fjs t` panics on one; the browser page catches it and reports one + * failed module. See `todo/hostile-proof-values.md`. + * + * A caller that has already collected the leaves — because it enumerates under + * its own guard, or because its modules are a list that may name the same + * module twice — calls {@link runEntries} directly instead. Enumerating is not + * idempotent: a getter in the export runs again on every read. + * + * @template {Operation} O + * @template R + * @param {Reporter} reporter + * @returns {(k: string, v: unknown) => Effect, IoChannel>} + */ +const runModule = reporter => (k, v) => + runEntries(reporter)(k, collectTests([], false, v)) + /** @type {(moduleMap: ModuleMap) => readonly (readonly [string, unknown])[]} */ const proofEntries = moduleMap => definedEntries(moduleMap) From e6b198245539e978244da797345dd659dfff0c92 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:12:19 +0000 Subject: [PATCH 212/370] edag: end chain lambdas by arity, not a null terminator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements fjs/edag/todo/option-terminated-lambdas.md, deleted here. A chain now ends by leaving the continuation operand out: a plain read is ['.', a, 'b'], a terminal call step is ['|()', c], and ['|!()', c] closes a region. Every kind that can end is an or() of its two closed arities, so null is a primitive again with no reading in a continuation position, and the argument the old spelling rested on is inverted: closedness by length is what rejects a continuation smuggled onto a terminal, which is why the terminal needs no explicit third operand. A trailing hole matches neither arm — the short one is bounded by length, the long one has no option member — so validate(exp) keeps rejecting it, pinned in the proofs alongside rejections for a present null, a present undefined, and the smuggled continuation. Sparse values are built with concat(new Array(1)), FunctionalScript having no hole literal. amnesia reads a continuation by destructuring everywhere, skip included: destructuring stops at length, so an absent continuation reads as undefined and never reaches the prototype, where an indexed k[2] would. Downstream designs that prescribe the old spellings are respelled (compile-modules-to-edag, interpret-edag, the bun-parentheses blocker); released changelog entries stay as written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- changelog/unreleased/1759.md | 15 + fjs/djs/todo/compile-modules-to-edag.md | 18 +- fjs/djs/todo/interpret-edag.md | 8 +- fjs/edag/README.md | 105 ++++--- fjs/edag/amnesia/README.md | 6 +- fjs/edag/amnesia/module.f.mjs | 37 ++- fjs/edag/amnesia/proof.f.mjs | 150 +++++----- fjs/edag/module.f.mjs | 129 ++++---- fjs/edag/proof.f.mjs | 262 +++++++++-------- fjs/edag/todo/option-terminated-lambdas.md | 276 ------------------ fjs/edag/types.ts | 36 ++- .../blocked/bun-optional-chain-parentheses.md | 2 +- 12 files changed, 441 insertions(+), 603 deletions(-) create mode 100644 changelog/unreleased/1759.md delete mode 100644 fjs/edag/todo/option-terminated-lambdas.md diff --git a/changelog/unreleased/1759.md b/changelog/unreleased/1759.md new file mode 100644 index 000000000..dd06eef3b --- /dev/null +++ b/changelog/unreleased/1759.md @@ -0,0 +1,15 @@ +- **BREAKING CHANGES:** `edag`: a chain ends by **arity**, not by a `null` + terminator. A plain property read is `['.', a, 'b']`, a terminal call step + is `['|()', c]`, and `['|!()', c]` closes a region — each one element + shorter than before, with the continuation operand present only where a + chain actually continues. Every kind that can end is now an `or` of its two + closed arities, so `null` is a primitive again and has no reading in a + continuation position: graphs written against the old spelling + (`['.', a, 'b', null]`, `['|()', c, null]`) no longer validate, and the + `Dot`, `OptionDot`, `OptionCall`, `PropertyLambda`, `OptionLambda` and + `OptionPropertyLambda` types no longer admit them. Closedness by length is + what keeps a continuation from being smuggled onto a terminal, which is + what the explicit `null` used to guard; a trailing hole matches neither + arity and is rejected as before. `amnesia` reads a continuation by + destructuring everywhere, `skip` included, so an absent one never reaches + the prototype. diff --git a/fjs/djs/todo/compile-modules-to-edag.md b/fjs/djs/todo/compile-modules-to-edag.md index 395e819d2..ab85dac6a 100644 --- a/fjs/djs/todo/compile-modules-to-edag.md +++ b/fjs/djs/todo/compile-modules-to-edag.md @@ -251,7 +251,7 @@ Also introduce call operations into EDAG: ```js ['()', object, args] // f(...args) -['.', object, property, ['|()', args, null]] // o.p(...args) +['.', object, property, ['|()', args]] // o.p(...args) ``` There are two call spellings and the receiver is what tells them apart. `()` is the @@ -260,10 +260,10 @@ call is instead the **property-access node owning its call** — the `'|()'` ste `.` node's continuation is what carries the `this` binding, which no `()` node can. See "Chains" in [`../../edag/README.md`](../../edag/README.md). Stage 2 needs neither optional node (`?.`, `?.()`) nor any of the other three steps, since optional chaining -is not in its source subset; a plain property read is `['.', object, property, null]`. +is not in its source subset; a plain property read is `['.', object, property]`. The property operand of a `.` node carrying a `'|()'` step follows **the same canonical -safety restriction as `.`** with a `null` continuation. +safety restriction as `.`** with no continuation. In this stage that means a permitted string constant or number constant; prohibited names, runtime-computed strings, and other unsupported property expressions are rejected. This is the EDAG form of the method-call distinction and safety rules already @@ -289,14 +289,14 @@ The staged work builds on the basic structural forms already being defined for E the current DJS parser produces; - array constructors: `['[]', [...node]]`; - the argument array: `['args']`; -- Stage 1 property access: `['.', object, property, null]`, with the restricted - property operands described above — the `null` is the continuation operand, saying - the receiver this access produced is dropped; +- Stage 1 property access: `['.', object, property]`, with the restricted + property operands described above — the absent fourth operand is the continuation, + and leaving it out says the receiver this access produced is dropped; - Stage 2 non-capturing functions: `['=>', null, body]` (`frame` is a general `exp` in the schema; `null` is what *this task's* parser and interpreter are scoped to, not a schema-level restriction); - Stage 2 calls: `['()', callee, args]` for an ordinary call, and - `['.', object, property, ['|()', args, null]]` for a method call, with the property + `['.', object, property, ['|()', args]]` for a method call, with the property operand using the same restriction as `.`; - semantic sharing by node identity, serialized with DJS `const` references when needed. @@ -479,11 +479,11 @@ task; see [`bound-edag-interpreter-resources.md`](./bound-edag-interpreter-resou - [ ] Validate that a nested function body is a disjoint EDAG scope: operation nodes must not be shared across a function boundary, while sharing within the body is preserved. -- [x] `['()', callee, args]` and the `['|()', args, null]` step a `.` node carries for +- [x] `['()', callee, args]` and the `['|()', args]` step a `.` node carries for a method call are in the EDAG validation/type schema (`fjs/edag/`), shape only — the property-operand restriction below is this stage's own work. - [ ] Convert the corresponding parser call expressions to the EDAG call forms — `()` - for an ordinary call, a `.` node with a `['|()', args, null]` continuation for a + for an ordinary call, a `.` node with a `['|()', args]` continuation for a method call; reject prohibited or runtime-computed string properties in that node rather than bypassing the property-access safety rule. - [ ] Add proofs for non-capturing nested functions and ordinary/method calls in the diff --git a/fjs/djs/todo/interpret-edag.md b/fjs/djs/todo/interpret-edag.md index ebc7efd9d..b452b8e7c 100644 --- a/fjs/djs/todo/interpret-edag.md +++ b/fjs/djs/todo/interpret-edag.md @@ -50,9 +50,9 @@ reuse the first call's `[1]`. Sharing of a body node remains memoized within eac individual invocation. As the compiler lands the staged operators, the direct interpreter should support the -same EDAG forms: Stage 1 adds `.` property access with a `null` continuation; Stage 2 +same EDAG forms: Stage 1 adds `.` property access with no continuation; Stage 2 adds non-capturing `=>`, the ordinary call `['()', callee, args]`, and the method call -— a `.` node whose continuation is `['|()', args, null]`, which is what carries the +— a `.` node whose continuation is `['|()', args]`, which is what carries the `this` binding. Stage 2 deliberately has **no frame support** — a restriction on *this interpreter*, not @@ -96,9 +96,9 @@ hardening TODO after the baseline interpreter exists. - [ ] Validate the final EDAG before interpretation. - [ ] Interpret EDAG operations directly; do not generate JavaScript from EDAG and run it through the host JavaScript engine. -- [ ] Support Stage 1 `['.', object, property, null]` property access. +- [ ] Support Stage 1 `['.', object, property]` property access. - [ ] Support Stage 2 `['=>', null, body]`, `['()', callee, args]` for an ordinary - call, and `['.', object, property, ['|()', args, null]]` for a method call — + call, and `['.', object, property, ['|()', args]]` for a method call — the step is what supplies the `this` binding — when those operators land. - [ ] Do **not** implement `['frame']` or non-empty closure frames in Stage 2. - [ ] Memoize results by EDAG node identity within one evaluation context so shared diff --git a/fjs/edag/README.md b/fjs/edag/README.md index 15c2b48a6..a4403f41b 100644 --- a/fjs/edag/README.md +++ b/fjs/edag/README.md @@ -68,14 +68,20 @@ vocabularies. | `['args']` | the function's arguments | | `['frame']` | the captured frame | | `['()', exp, exp]` | call with no receiver: `exp0(...exp1)` — see [Chains](#chains) | -| `['.', exp, index, propertyLambda]` | property access `exp0[exp1]`, owning whatever its receiver is used for | -| `['?.', exp, index, optionPropertyLambda]` | optional property access `exp0?.[exp1]`, owning the rest of its optional region | -| `['?.()', exp, exp, optionLambda]` | optional call `exp0?.(...exp1)`, likewise | -| `['\|()', exp, k]`, `['\|.', index, k]`, `['\|?.()', exp, k]`, `['\|!()', exp, null]` | a chain step and its continuation — only valid in the continuation operand of a node above, or of another step | +| `['.', exp, index]`, `['.', exp, index, propertyLambda]` | property access `exp0[exp1]`, owning whatever its receiver is used for | +| `['?.', exp, index]`, `['?.', exp, index, optionPropertyLambda]` | optional property access `exp0?.[exp1]`, owning the rest of its optional region | +| `['?.()', exp, exp]`, `['?.()', exp, exp, optionLambda]` | optional call `exp0?.(...exp1)`, likewise | +| `['\|()', exp, k?]`, `['\|.', index, k?]`, `['\|?.()', exp, k?]`, `['\|!()', exp]` | a chain step and, where the chain continues, its continuation — only valid in the continuation operand of a node above, or of another step | | `[',', exps]` | comma: establish all operands, take the value of the last | | `[id, exp]` | unary operation, `id` one of `String` `Number` `neg` `!` `~` | | `[id, exp, exp]` | binary operation, `id` one of `=>` `own` `===` `!==` `>` `>=` `<` `<=` `+` `-` `*` `/` `%` `**` `&` `\|` `^` `<<` `>>` `>>>` `&&` `\|\|` `??` | +Where a form is listed twice above, the two are the node's arities: the +shorter one ends the chain and the longer one hands it on, and the schema is +their union. A `k?` in the step row says the same thing one level down. That +is the whole of how a chain ends — there is no terminator value, so `null` in +a continuation position is simply not one of these forms. + A `[]` suffix in the form column marks an operand that is an array of the named schema, not one of it: `['[]', items[]]` holds a whole array of `items`, and `exps` is likewise `exp[]`. The distinction is easy to lose in @@ -93,9 +99,12 @@ the array operand is the decided representation rather than a stand-in for a flat one — [`todo/edag-stage1-discussion.md`](../../todo/edag-stage1-discussion.md) writes the same shape. -A continuation is **not** an array. It is `null` or one step holding the next +A continuation is **not** an array. It is one step holding the next continuation, so a chain is a linked list whose link type changes as it goes — -which link type is legal where is the whole of [Chains](#chains) below. +which link type is legal where is the whole of [Chains](#chains) below. The +list ends by **arity**: the step or node that ends it is simply the shorter +tuple, with no continuation operand at all, which is why every kind that can +end is a union of its two closed lengths. An `index` — the property operand of `.`, `?.`, and the `|.` step — is a `string`, a `number`, or `['Number', exp]`, a computed index cast to a @@ -130,7 +139,7 @@ control flow has to be born, carried, and consumed inside one node — and the node's **continuation** operand is where it is carried. A continuation is a *lambda*: a function of the chain's current value whose argument is elided, which is what the name says. It is not an `exp` and cannot be lifted out as a -shared node — `['|.', 'b', null]` means nothing on its own. +shared node — `['|.', 'b']` means nothing on its own. ### Two bits, three lambda types @@ -158,7 +167,7 @@ produces a bare value, which is why it alone has no continuation operand. | `['\|.', index, k]` | sets P, keeps O | property access; the input becomes the receiver | | `['\|()', exp, k]` | clears P, keeps O | call the current value with the current receiver | | `['\|?.()', exp, k]` | clears P, **sets** O | the same, `undefined` on a nullish current value — and the region it opens owns the rest of the chain | -| `['\|!()', exp, null]` | clears P, **clears** O | the same as `\|()`, but *outside* the region: the parentheses ended it, so a short-circuit does not skip this step | +| `['\|!()', exp]` | clears P, **clears** O | the same as `\|()`, but *outside* the region: the parentheses ended it, so a short-circuit does not skip this step | `?` adds a guard and `!` escapes one, which makes the three call steps a complete taxonomy of how a call can relate to the region it sits in: @@ -190,9 +199,11 @@ and which bit says why it cannot be a node instead: | | `\|?.()` | O and P | O | | | `\|!()` | P — the region is closing anyway | *(terminal)* | -`null` is every state's third exit — the chain simply ends and any live bit -is dropped, which is also the correct spelling of a bare `(a?.b)`, since -closing a region with nothing after it is unobservable. +Leaving the continuation operand out is every state's third exit — the chain +simply ends and any live bit is dropped, which is also the correct spelling of +a bare `(a?.b)`, since closing a region with nothing after it is +unobservable. Ending is therefore an absence, not a value: `null` is a +primitive again, and it has no reading in a continuation position. What is *absent* carries as much as what is present. `|!()` outside a region is not a design decision — there is no bit to clear. The three real decisions @@ -208,24 +219,24 @@ throws where `a?.b.c` does not. | JS | EDAG | |---|---| -| `a.b` | `['.', a, 'b', null]` | -| `a.b.c` | `['.', ['.', a, 'b', null], 'c', null]` | -| `a.b(...c)` | `['.', a, 'b', ['\|()', c, null]]` | -| `(0, a.b)(...c)` | `['()', ['.', a, 'b', null], c]` | -| `a.b?.(...c)` | `['.', a, 'b', ['\|?.()', c, null]]` | +| `a.b` | `['.', a, 'b']` | +| `a.b.c` | `['.', ['.', a, 'b'], 'c']` | +| `a.b(...c)` | `['.', a, 'b', ['\|()', c]]` | +| `(0, a.b)(...c)` | `['()', ['.', a, 'b'], c]` | +| `a.b?.(...c)` | `['.', a, 'b', ['\|?.()', c]]` | | `f(...c)` | `['()', f, c]` | -| `a?.b` | `['?.', a, 'b', null]` | -| `a?.b.c` | `['?.', a, 'b', ['\|.', 'c', null]]` | -| `(a?.b).c` | `['.', ['?.', a, 'b', null], 'c', null]` | -| `a?.b(...c)` | `['?.', a, 'b', ['\|()', c, null]]` | -| `a?.b?.(...c)` | `['?.', a, 'b', ['\|?.()', c, null]]` | -| `(a?.b)(...c)` | `['?.', a, 'b', ['\|!()', c, null]]` | -| `(a?.b.c)(...d)` | `['?.', a, 'b', ['\|.', 'c', ['\|!()', d, null]]]` | -| `(a?.b).c(...d)` | `['.', ['?.', a, 'b', null], 'c', ['\|()', d, null]]` | -| `a?.b(...c).d(...e)` | `['?.', a, 'b', ['\|()', c, ['\|.', 'd', ['\|()', e, null]]]]` | -| `a?.(...c)` | `['?.()', a, c, null]` | -| `a?.(...c).d` | `['?.()', a, c, ['\|.', 'd', null]]` | -| `(a?.(...c))(...d)` | `['()', ['?.()', a, c, null], d]` | +| `a?.b` | `['?.', a, 'b']` | +| `a?.b.c` | `['?.', a, 'b', ['\|.', 'c']]` | +| `(a?.b).c` | `['.', ['?.', a, 'b'], 'c']` | +| `a?.b(...c)` | `['?.', a, 'b', ['\|()', c]]` | +| `a?.b?.(...c)` | `['?.', a, 'b', ['\|?.()', c]]` | +| `(a?.b)(...c)` | `['?.', a, 'b', ['\|!()', c]]` | +| `(a?.b.c)(...d)` | `['?.', a, 'b', ['\|.', 'c', ['\|!()', d]]]` | +| `(a?.b).c(...d)` | `['.', ['?.', a, 'b'], 'c', ['\|()', d]]` | +| `a?.b(...c).d(...e)` | `['?.', a, 'b', ['\|()', c, ['\|.', 'd', ['\|()', e]]]]` | +| `a?.(...c)` | `['?.()', a, c]` | +| `a?.(...c).d` | `['?.()', a, c, ['\|.', 'd']]` | +| `(a?.(...c))(...d)` | `['()', ['?.()', a, c], d]` | The `chains` section of [proof.f.mjs](proof.f.mjs) pins the shape of every spelling above, `chainsJs` next to it runs them as JS on the host engine, and @@ -247,19 +258,22 @@ section of [proof.f.mjs](proof.f.mjs) is one case per family. The same holds for dead prefixes: `propertyLambda` has no `|.` production, so plain property paths nest and `a.b.c` has exactly one spelling. "Exactly one" is literal rather than "up to trailing junk", because every tuple in the -schema is closed — `['.', a, 'b', null, 'extra']` does not validate. +schema is closed — `['.', a, 'b', k, 'extra']` does not validate. Two things the vocabulary makes disjoint deserve stating, because neither is cosmetic. **The `|` prefix is a correctness requirement.** Unprefixed, -`['()', f, null]` would be simultaneously a well-formed `()` node — call `f` -with `null` as its arguments — and a well-formed `optionLambda` — call the -chain's value with `f` as its arguments, and stop. The two readings have the -same length, so closedness could not have separated them — it bounds a tuple's -length and says nothing about its tag; only disjoint vocabularies can. **Terminals state their `null`.** `propertyLambda`'s `|()` -and `optionPropertyLambda`'s `|!()` end the chain, and they say so with an -explicit third operand rather than by being one element shorter: a -two-element terminal handed a real continuation would validate as the -terminal with the rest silently dropped. +`['()', f, k]` would read as a well-formed `()` node — call `f` with `k` as +its arguments — and as a well-formed step — call the chain's value with `f` as +its arguments, then continue with `k`. Closedness bounds a tuple's length and +says nothing about its tag, so no arity separates those readings; only +disjoint vocabularies can, and the prefix does it without anyone having to +prove that a continuation could never also be an expression. +**Closedness by length is what a terminal rests on.** `propertyLambda`'s +`|()` and `optionPropertyLambda`'s `|!()` end the chain and have only the +two-element arity, so a continuation handed to one is a third element the +tuple does not declare, and the value is rejected rather than accepted with +the rest silently dropped — which is exactly what the length check gives and +what an `open` tuple would take away. ### Where the host engines disagree @@ -269,7 +283,7 @@ chain, so `undefined` is called. V8 does throw; JavaScriptCore (hence `bun test`) carries the short-circuit through the parentheses and evaluates to `undefined` instead. That case is exactly the `|!()` step, so no JavaScript oracle can establish it on every supported runner. The EDAG follows the -specification — `['?.', u, 'b', ['|!()', d, null]]` denotes the throwing +specification — `['?.', u, 'b', ['|!()', d]]` denotes the throwing reading, and an executor must produce it whatever its host does — as [amnesia](amnesia/module.f.mjs) does, where `optionRegion.throw.closeStepOnUndefined` in @@ -309,14 +323,17 @@ unblocks them, in ### The cost -Every property access carries a continuation operand, so a plain `a.b` is -`['.', a, 'b', null]` in every graph: more tuple elements to store and hash, -though no ambiguity, since `propertyLambda` has no `|.` production and a -property path keeps its unique spelling. +A plain `a.b` is `['.', a, 'b']`, so a property access that ends its chain +costs nothing beyond the access itself — the continuation operand is present +only where a chain actually continues. The price is paid in the schema +instead: every kind that can end is written twice, once per arity, so the +shared prefix appears in both arms. There is no ambiguity, since +`propertyLambda` has no `|.` production and a property path keeps its unique +spelling. The deeper cost is purity, and it is unchanged from any other shape that spells chains out of steps. A continuation is structured now, but it is still -not an `exp`: the `a.b` inside `['.', a, 'b', ['|?.()', c, null]]` cannot be +not an `exp`: the `a.b` inside `['.', a, 'b', ['|?.()', c]]` cannot be shared, substituted, or hashed. That is the price of expressing control flow that no value can carry, and it is confined to exactly the positions that need it. diff --git a/fjs/edag/amnesia/README.md b/fjs/edag/amnesia/README.md index 1ecd650ca..f2552b6d5 100644 --- a/fjs/edag/amnesia/README.md +++ b/fjs/edag/amnesia/README.md @@ -15,7 +15,7 @@ meaning. Two of its sections, `ownJs` and `chainsJs`, have to run **JavaScript** to pin the behavior the nodes are built around, because until this module existed nothing could run an EDAG. Its own [`proof.f.mjs`](./proof.f.mjs) is what that gap was waiting for: `['+', 2, 3]` is `5` and -`['&&', false, ['.', null, 'x', null]]` short-circuits are now claims a test +`['&&', false, ['.', null, 'x']]` short-circuits are now claims a test makes by evaluating the node, not by evaluating the JavaScript it was modeled on. @@ -54,8 +54,8 @@ preserve identity, and what each is for, are in `.` is `a[b]`, so the entire JavaScript prototype chain is reachable: ```js -vm(context)(['.', ['=>', ['[]', []], 1], 'constructor', null]) // Function -vm(context)(['.', ['{}', []], '__proto__', null]) // resolves +vm(context)(['.', ['=>', ['[]', []], 1], 'constructor']) // Function +vm(context)(['.', ['{}', []], '__proto__']) // resolves ``` [`spec/todo/2360-built-in.md`](../../../spec/todo/2360-built-in.md) lists both diff --git a/fjs/edag/amnesia/module.f.mjs b/fjs/edag/amnesia/module.f.mjs index 4fe59c341..38a8eb1fd 100644 --- a/fjs/edag/amnesia/module.f.mjs +++ b/fjs/edag/amnesia/module.f.mjs @@ -102,13 +102,20 @@ const callProperty = (f, obj, prop, e) => obj[prop](...argsOf(f, e)) * every step is `[tag, operand, continuation]`, and a `|!()` is reachable * through `|.` steps from either — `(a?.(...b).c)(...d)` is exactly that. * - * @type {(f: (_: Exp) => unknown, k: OptionLambda | OptionPropertyLambda) => unknown} + * Like the three walkers below it reads a step by **destructuring**, never by + * index: destructuring goes through the array iterator, which stops at + * `length`, so a short step's absent continuation reads as `undefined` and + * never as whatever a prototype supplies at that index. An indexed `k[2]` + * would, which is why none appears here. + * + * @type {(f: (_: Exp) => unknown, k: OptionLambda | OptionPropertyLambda | undefined) => unknown} */ const skip = (f, k) => { - if (k === null) { return undefined } - return k[0] === '|!()' - ? callValue(f, undefined, k[1]) - : skip(f, k[2]) + if (k === undefined) { return undefined } + const [o, e, cont] = k + return o === '|!()' + ? callValue(f, undefined, e) + : skip(f, cont) } /** @@ -116,10 +123,10 @@ const skip = (f, k) => { * step leaves. Nothing here can short-circuit: the two productions are a call * that stays in the region and a property access that hands on a receiver. * - * @type {(f: (_: Exp) => unknown, v: unknown, k: OptionLambda) => unknown} + * @type {(f: (_: Exp) => unknown, v: unknown, k: OptionLambda | undefined) => unknown} */ const optionLambda = (f, v, k) => { - if (k === null) { return v } + if (k === undefined) { return v } const [o, e, cont] = k switch (o) { case '|()': return optionLambda(f, callValue(f, v, e), cont) @@ -137,10 +144,10 @@ const optionLambda = (f, v, k) => { * `obj[prop]` is read once per step, twice only where the guard has to see * the value before the call is made. * - * @type {(f: (_: Exp) => unknown, obj: any, prop: any, k: OptionPropertyLambda) => unknown} + * @type {(f: (_: Exp) => unknown, obj: any, prop: any, k: OptionPropertyLambda | undefined) => unknown} */ const optionPropertyLambda = (f, obj, prop, k) => { - if (k === null) { return obj[prop] } + if (k === undefined) { return obj[prop] } const [o, e, cont] = k switch (o) { case '|.': return optionPropertyLambda(f, obj[prop], f(e), cont) @@ -160,10 +167,10 @@ const optionPropertyLambda = (f, obj, prop, k) => { * node's value, since `optionLambda` has no `|!()` of its own — but the walk * still goes through `skip`, which reaches one through a `|.`. * - * @type {(f: (_: Exp) => unknown, obj: any, prop: any, k: PropertyLambda) => unknown} + * @type {(f: (_: Exp) => unknown, obj: any, prop: any, k: PropertyLambda | undefined) => unknown} */ const propertyLambda = (f, obj, prop, k) => { - if (k === null) { return obj[prop] } + if (k === undefined) { return obj[prop] } const [o, e, cont] = k switch (o) { case '|()': return callProperty(f, obj, prop, e) @@ -196,9 +203,11 @@ const map = { return a.reduce((/**@type {unknown}*/_, c) => f(c), undefined) }, '-': o2((a, b) => a - b), - // Property access, owning whatever its receiver is used for: a `null` - // continuation drops it, as reading `a.b` for its value does, and the two - // call steps are the only things that can spend it. + // Property access, owning whatever its receiver is used for: with no + // continuation operand the receiver is dropped, as reading `a.b` for its + // value does, and the two call steps are the only things that can spend + // it. The node is destructured, so a three-element `['.', a, k]` reads + // its absent fourth as `undefined` without touching the prototype. '.': (x, [, a, k, p]) => { const i = vm(x) return propertyLambda(i, i(a), i(k), p) diff --git a/fjs/edag/amnesia/proof.f.mjs b/fjs/edag/amnesia/proof.f.mjs index 9fde723ef..9b70098ad 100644 --- a/fjs/edag/amnesia/proof.f.mjs +++ b/fjs/edag/amnesia/proof.f.mjs @@ -37,7 +37,7 @@ const same = (e, expected) => { assertStructurallySame(ev(e), expected) } * `throw` section calls its forced counterparts. * @type {Exp} */ -const boom = ['.', null, 'x', null] +const boom = ['.', null, 'x'] /** * The same, in a naming position: an `index` is a `string`, a `number`, or a @@ -57,7 +57,7 @@ const noArgs = ['[]', []] * case is about the call and not about what the callee computes. * @type {Exp} */ -const identity = ['=>', ['[]', []], ['.', ['args'], 0, null]] +const identity = ['=>', ['[]', []], ['.', ['args'], 0]] /** `(...a) => a` — hands back the whole argument array. @type {Exp} */ const argsNode = ['=>', ['[]', []], ['args']] @@ -152,15 +152,15 @@ export const proof = { eq(['>>', -8, 1], -4) eq(['>>>', -1, 31], 1) }, - // `.` with a `null` continuation is the plain read — the receiver it + // `.` with no continuation operand is the plain read — the receiver it // produced is dropped, exactly as reading `a.b` for its value drops it. // `own` is the `o2` next to it, and they differ on the prototype chain. property: () => { - eq(['.', ['[]', [1, 2, 3]], 1, null], 2) - eq(['.', ['{}', [[':', 'a', 7]]], 'a', null], 7) + eq(['.', ['[]', [1, 2, 3]], 1], 2) + eq(['.', ['{}', [[':', 'a', 7]]], 'a'], 7) // An `index` is a string, a number, or `['Number', exp]`, and all // three name a property the same way. - eq(['.', ['[]', [1, 2, 3]], ['Number', '1'], null], 2) + eq(['.', ['[]', [1, 2, 3]], ['Number', '1']], 2) eq(['own', ['{}', [[':', 'a', 7]]], 'a'], 7) // Absent: no descriptor, so `?.value` is `undefined` rather than a // read of `undefined.value`. @@ -172,7 +172,7 @@ export const proof = { // `own`'s key operand must *evaluate* to one, so `1` is rejected // rather than silently reading `'1'` — see `throw.ownNonStringKey`. eq(['own', ['{}', [[':', '1', 42]]], '1'], 42) - assert(typeof ev(['.', ['{}', []], 'toString', null]) === 'function') + assert(typeof ev(['.', ['{}', []], 'toString']) === 'function') }, // `o2lazy` — the right operand is a thunk, so these three short-circuit. // Each case that claims "not evaluated" uses `boom`, which throws if it @@ -229,10 +229,10 @@ export const proof = { // every other node kind and sees the same context at any depth. nested: () => { eq(['+', ['+', 1, 2], 3], 6) - eq(['.', ['args'], 1, null], 20) - eq(['.', ['frame'], 'x', null], 1) + eq(['.', ['args'], 1], 20) + eq(['.', ['frame'], 'x'], 1) same( - ['{}', [[':', 'a', ['[]', [['.', ['args'], 0, null], ['neg', 1]]]]]], + ['{}', [[':', 'a', ['[]', [['.', ['args'], 0], ['neg', 1]]]]]], { a: [10, -1] }, ) }, @@ -264,7 +264,7 @@ export const proof = { same(['()', argsNode, ['[]', [1, ['...', ['[]', [2, 3]]]]]], [1, 2, 3]) // Operands are evaluated in the *caller's* scope, before the callee's // exists: the callee expression as much as the arguments. - eq(['()', ['.', ['[]', [identity]], 0, null], ['[]', [['+', 3, 4]]]], 7) + eq(['()', ['.', ['[]', [identity]], 0], ['[]', [['+', 3, 4]]]], 7) }, // The continuation of a `.` node — `propertyLambda`, the state with a // live receiver and no region around it. A step is a function of the @@ -273,43 +273,43 @@ export const proof = { // exists only while the chain is being walked. Only the two call steps // are here, because only a call spends a receiver. chain: { - // `['|()', exp, null]` — the terminal call step. The value called is + // `['|()', exp]` — the terminal call step. The value called is // the property, and the object it came from is the receiver // (`receiver`, below). callStep: () => { // a.b(...c) - eq(['.', methods, 'id', ['|()', ['[]', [7]], null]], 7) + eq(['.', methods, 'id', ['|()', ['[]', [7]]]], 7) // (a.b.c)(...d) — a plain property path nests, and a non-optional // chain means the same parenthesized or not. - eq(['.', ['.', methods, 'o', null], 'id', ['|()', ['[]', [7]], null]], 7) + eq(['.', ['.', methods, 'o'], 'id', ['|()', ['[]', [7]]]], 7) // The args operand is still one node evaluating to the whole // argument array: a chain changes what is called, not how it is // called. - same(['.', methods, 'args', ['|()', ['[]', [5, 6]], null]], [5, 6]) - same(['.', methods, 'args', ['|()', noArgs, null]], []) + same(['.', methods, 'args', ['|()', ['[]', [5, 6]]]], [5, 6]) + same(['.', methods, 'args', ['|()', noArgs]], []) // The three `index` forms, in the naming position of the node // that owns the call. - eq(['.', ['[]', [identity]], 0, ['|()', ['[]', [7]], null]], 7) - eq(['.', ['[]', [identity]], ['Number', '0'], ['|()', ['[]', [7]], null]], 7) + eq(['.', ['[]', [identity]], 0, ['|()', ['[]', [7]]]], 7) + eq(['.', ['[]', [identity]], ['Number', '0'], ['|()', ['[]', [7]]]], 7) }, // `['|?.()', exp, k]` — the guarded call step: it spends the receiver // and *opens* a region, so unlike `|()` it carries a continuation. // With a non-nullish value it behaves exactly as `|()` does. optionCallStep: () => { // a.b?.(...c) - eq(['.', methods, 'id', ['|?.()', ['[]', [7]], null]], 7) + eq(['.', methods, 'id', ['|?.()', ['[]', [7]]]], 7) // a.b?.(...c).d(...e) — the region it opened owns the rest. eq(['.', ['{}', [[':', 'g', constMethods]]], 'g', ['|?.()', noArgs, - ['|.', 'id', ['|()', ['[]', [7]], null]]]], 7) + ['|.', 'id', ['|()', ['[]', [7]]]]]], 7) }, // ... and the guard is the whole difference: on a nullish value the // region opens and immediately short-circuits, so the node is // `undefined` rather than a call on nothing — and neither the // arguments nor any later step runs. optionCallStepSkips: () => { - eq(['.', ['{}', []], 'absent', ['|?.()', boom, null]], undefined) + eq(['.', ['{}', []], 'absent', ['|?.()', boom]], undefined) eq(['.', ['{}', [[':', 'b', null]]], 'b', ['|?.()', boom, - ['|.', boomIndex, ['|()', boom, null]]]], undefined) + ['|.', boomIndex, ['|()', boom]]]], undefined) }, // The receiver is what a property step leaves behind, and it is // real rather than bookkeeping: `[42].at(0)` is `42` only because @@ -318,22 +318,22 @@ export const proof = { // (`throw.detachedReceiver`) — the pair `chainsJs.receiver` makes in // JavaScript, made here by the nodes. receiver: () => { - eq(['.', ['[]', [42]], 'at', ['|()', ['[]', [0]], null]], 42) - eq(['.', ['[]', [42]], 'at', ['|?.()', ['[]', [0]], null]], 42) + eq(['.', ['[]', [42]], 'at', ['|()', ['[]', [0]]]], 42) + eq(['.', ['[]', [42]], 'at', ['|?.()', ['[]', [0]]]], 42) // A call step consumed the receiver of the step before it, so // `'ab'.at(0).toUpperCase()` needs a second `.` node to make its // own — which is exactly why `|()` is terminal here. eq(['.', - ['.', 'ab', 'at', ['|()', ['[]', [0]], null]], + ['.', 'ab', 'at', ['|()', ['[]', [0]]]], 'toUpperCase', - ['|()', noArgs, null]], 'A') + ['|()', noArgs]], 'A') }, throw: { // `const at = a.at; at(0)` — the receiver a `.` node keeps for - // the call it owns is exactly what a `null` continuation drops, + // the call it owns is exactly what the shorter arity drops, // and the host method is strict, so the detached call throws. detachedReceiver: () => - ev(['()', ['.', ['[]', [42]], 'at', null], ['[]', [0]]]), + ev(['()', ['.', ['[]', [42]], 'at'], ['[]', [0]]]), // `((a.at)(0))(0)` — the same detachment reached through a call // node, so the callee is a bare value rather than an accessor. // A host method is what makes that observable: an `=>` closure @@ -344,21 +344,21 @@ export const proof = { // in `./module.f.mjs`. detachedReceiverAfterCall: () => ev(['()', - ['()', ['.', ['[]', [42]], 'at', null], ['[]', [0]]], + ['()', ['.', ['[]', [42]], 'at'], ['[]', [0]]], ['[]', [0]]]), // A step is only as good as what it lands on: a call step onto a // value that is not callable reaches the same host `TypeError` // as `throw.callNonFunction`, one node earlier. callStepOnNonFunction: () => - ev(['.', ['{}', [[':', 'a', 1]]], 'a', ['|()', noArgs, null]]), + ev(['.', ['{}', [[':', 'a', 1]]], 'a', ['|()', noArgs]]), // A `.` node guards nothing, so a nullish base throws at the // access — the operand-evaluation half of the pair // `../proof.f.mjs`'s `chainsJs.throw` cannot state in JavaScript: // here the arguments are never reached, where // `optionRegion.throw.closeStepOnUndefined` evaluates them and // then calls `undefined`. - propertyOnUndefined: () => ev(['.', undef, 'at', ['|()', noArgs, null]]), - propertyOnNull: () => ev(['.', null, 'at', ['|()', noArgs, null]]), + propertyOnUndefined: () => ev(['.', undef, 'at', ['|()', noArgs]]), + propertyOnNull: () => ev(['.', null, 'at', ['|()', noArgs]]), }, }, // `?.` — the node that opens an optional *region*: its own `?.[index]` @@ -370,59 +370,59 @@ export const proof = { // a?.b — the node's own step, which is the whole node when the // continuation is `null`. Reading `a` and skipping the step would // evaluate to `a` itself, so these pin the index is applied. - eq(['?.', ['{}', [[':', 'a', 7]]], 'a', null], 7) + eq(['?.', ['{}', [[':', 'a', 7]]], 'a'], 7) // A closure is a value like any other — compared by `typeof`, since // every evaluation of a `=>` builds a fresh one (see `lambda`). - assert(typeof ev(['?.', methods, 'id', null]) === 'function') - same(['?.', ['[]', [1, 2, 3]], 1, null], 2) - eq(['?.', ['[]', [1, 2, 3]], ['Number', '1'], null], 2) + assert(typeof ev(['?.', methods, 'id']) === 'function') + same(['?.', ['[]', [1, 2, 3]], 1], 2) + eq(['?.', ['[]', [1, 2, 3]], ['Number', '1']], 2) // An absent property is `undefined`, not an error: `?.` guards its // *input*, never its result. - eq(['?.', ['{}', []], 'absent', null], undefined) + eq(['?.', ['{}', []], 'absent'], undefined) // ... and on a nullish input the node is `undefined`, both ways of // being nullish. - eq(['?.', undef, 'a', null], undefined) - eq(['?.', null, 'a', null], undefined) + eq(['?.', undef, 'a'], undefined) + eq(['?.', null, 'a'], undefined) // a?.b.c — `|.` continues the region, handing the receiver on within // it, and the steps run when nothing short-circuited. eq(['?.', ['{}', [[':', 'o', ['{}', [[':', 'a', 7]]]]]], 'o', - ['|.', 'a', null]], 7) + ['|.', 'a']], 7) // a?.b(...c) — `|()` inherits the region's guard and the receiver // survives into it, which is why `?.` owns its call rather than // evaluating to a value a `()` node would then have to call: // `[42]?.at(0)` is `42` only if `at` is called *on* the array. - eq(['?.', ['[]', [42]], 'at', ['|()', ['[]', [0]], null]], 42) + eq(['?.', ['[]', [42]], 'at', ['|()', ['[]', [0]]]], 42) // a?.b?.(...c) — `|?.()` adds its own guard on top of the region's. - eq(['?.', ['[]', [42]], 'at', ['|?.()', ['[]', [0]], null]], 42) + eq(['?.', ['[]', [42]], 'at', ['|?.()', ['[]', [0]]]], 42) // (a?.b)(...c) — `|!()` escapes the region, and keeps the receiver: // the parentheses end the chain, they do not detach the reference. - eq(['?.', ['[]', [42]], 'at', ['|!()', ['[]', [0]], null]], 42) + eq(['?.', ['[]', [42]], 'at', ['|!()', ['[]', [0]]]], 42) // (a?.b.c)(...d) — the same close one property step further in. - eq(['?.', methods, 'o', ['|.', 'id', ['|!()', ['[]', [7]], null]]], 7) + eq(['?.', methods, 'o', ['|.', 'id', ['|!()', ['[]', [7]]]]], 7) // a?.b.c?.(...d) — the guarded call reached through a property step, // which is the region handing `optionPropertyLambda` back to itself. - eq(['?.', methods, 'o', ['|.', 'id', ['|?.()', ['[]', [7]], null]]], 7) + eq(['?.', methods, 'o', ['|.', 'id', ['|?.()', ['[]', [7]]]]], 7) // a?.b(...c).d(...e) — one region across two calls, the second // making its own receiver. eq(['?.', ['{}', [[':', 'g', constMethods]]], 'g', - ['|()', noArgs, ['|.', 'id', ['|()', ['[]', [7]], null]]]], 7) + ['|()', noArgs, ['|.', 'id', ['|()', ['[]', [7]]]]]], 7) }, // `?.()` — the other region-opening node. Its callee is an ordinary // expression, so it never carries a receiver; what it owns is the rest // of the region, run on the call's result. optionCall: () => { // f?.(...c) - eq(['?.()', identity, ['[]', [7]], null], 7) + eq(['?.()', identity, ['[]', [7]]], 7) // ... and the args operand is one node evaluating to the whole // argument array, as everywhere else a call takes one. - same(['?.()', argsNode, ['[]', [5, 6]], null], [5, 6]) + same(['?.()', argsNode, ['[]', [5, 6]]], [5, 6]) // f?.(...c)(...d) — `|()` stays inside the region. - eq(['?.()', constIdentity, noArgs, ['|()', ['[]', [7]], null]], 7) + eq(['?.()', constIdentity, noArgs, ['|()', ['[]', [7]]]], 7) // f?.(...c).d(...e) — `|.` makes a receiver for the call after it, // which is the receiver chain `../README.md` gives as the reason // there is no `.()` node. eq(['?.()', constMethods, noArgs, - ['|.', 'id', ['|()', ['[]', [7]], null]]], 7) + ['|.', 'id', ['|()', ['[]', [7]]]]], 7) }, // The short-circuit, which is what the two region-opening nodes exist // for: they return rather than throw, so — unlike a `.` node, where @@ -433,31 +433,31 @@ export const proof = { // u?.b.c is `undefined`, where `(u?.b).c` throws: one region // against two nodes (`../README.md`, "Chains"). `boomIndex` as // the skipped step's index would throw if the step ran. - eq(['?.', undef, 'a', ['|.', boomIndex, null]], undefined) + eq(['?.', undef, 'a', ['|.', boomIndex]], undefined) // u?.b(...c) is `undefined`, where `(u?.b)(...c)` throws — the // pair `throw.closeStepOnUndefined` completes. The skipped // call's arguments are not evaluated either. - eq(['?.', undef, 'at', ['|()', boom, null]], undefined) + eq(['?.', undef, 'at', ['|()', boom]], undefined) // The node's own index is skipped too, which is the operand // `../proof.f.mjs`'s `chainsJs.shortCircuit` pins in JavaScript // as `u?.[todo()]`. - eq(['?.', undef, boomIndex, null], undefined) - eq(['?.', null, boomIndex, ['|.', boomIndex, null]], undefined) + eq(['?.', undef, boomIndex], undefined) + eq(['?.', null, boomIndex, ['|.', boomIndex]], undefined) // A guarded step mid-region short-circuits the same way: here // `a.b` is `undefined`, so `|?.()` skips itself and everything // after it. eq(['?.', ['{}', [[':', 'b', undef]]], 'b', - ['|?.()', boom, ['|.', boomIndex, null]]], undefined) + ['|?.()', boom, ['|.', boomIndex]]], undefined) // The nullish value need not be the node's own input: a property // step reading an absent property produces one mid-region, and // the guard after it skips the rest. - eq(['?.', methods, 'absent', ['|?.()', boom, null]], undefined) + eq(['?.', methods, 'absent', ['|?.()', boom]], undefined) // f?.(...c) with a nullish `f`: `undefined`, and the arguments // are not evaluated. Both ways of being nullish. - eq(['?.()', undef, boom, null], undefined) - eq(['?.()', null, boom, null], undefined) + eq(['?.()', undef, boom], undefined) + eq(['?.()', null, boom], undefined) // ... and the continuation is skipped along with the call. - eq(['?.()', undef, boom, ['|.', boomIndex, ['|()', boom, null]]], + eq(['?.()', undef, boom, ['|.', boomIndex, ['|()', boom]]], undefined) }, throw: { @@ -471,33 +471,33 @@ export const proof = { // commented out. The node denotes the throw regardless — see // "Chains" in `../README.md`. closeStepOnUndefined: () => - ev(['?.', undef, 'at', ['|!()', noArgs, null]]), + ev(['?.', undef, 'at', ['|!()', noArgs]]), closeStepOnNull: () => - ev(['?.', null, 'at', ['|!()', noArgs, null]]), + ev(['?.', null, 'at', ['|!()', noArgs]]), // `(u?.b.c)(...d)` — the same, reached past a skipped `|.`: the // walk that drops steps has to keep looking for the close rather // than stop at the first one it skips. closeStepPastSkippedProperty: () => - ev(['?.', undef, 'at', ['|.', boomIndex, ['|!()', noArgs, null]]]), + ev(['?.', undef, 'at', ['|.', boomIndex, ['|!()', noArgs]]]), // `(u?.(...a).c)(...d)` — and it reaches one from the other // region-opening node too, through the `|.` that leaves // `optionLambda` for `optionPropertyLambda`. closeStepAfterOptionCall: () => - ev(['?.()', undef, boom, ['|.', boomIndex, ['|!()', noArgs, null]]]), + ev(['?.()', undef, boom, ['|.', boomIndex, ['|!()', noArgs]]]), // `(a.absent?.(...b).m)(...d)` — and from a `.` node, whose // `|?.()` opens a region that short-circuits at once. That is the // third and last entry to `skip`, so between them the three cases // cover every state a region can be abandoned in. closeStepAfterPropertyGuard: () => ev(['.', methods, 'absent', - ['|?.()', boom, ['|.', boomIndex, ['|!()', noArgs, null]]]]), + ['|?.()', boom, ['|.', boomIndex, ['|!()', noArgs]]]]), // `(a.absent?.(...b))(...d)` — the same short-circuit under a // *node* boundary instead of a step: the `.` node evaluates to // `undefined` and the `()` over it calls that. The step spelling // above and this one are the two halves of the parenthesis law // at the same place, and they agree. callOfSkippedGuard: () => - ev(['()', ['.', methods, 'absent', ['|?.()', boom, null]], noArgs]), + ev(['()', ['.', methods, 'absent', ['|?.()', boom]], noArgs]), }, }, // The frame is the only channel outward: a body's leaves are constants, @@ -505,20 +505,20 @@ export const proof = { closure: () => { // `['=>', ['[]', [100]], …]` captures `100` at closure-creation time. eq(['()', ['=>', ['[]', [100]], - ['+', ['.', ['args'], 0, null], ['.', ['frame'], 0, null]]], + ['+', ['.', ['args'], 0], ['.', ['frame'], 0]]], ['[]', [5]]], 105) // Nested: the outer call's argument is copied into the inner frame, // and the inner body reads it as `['frame']` — the same node - // `['.', ['args'], 0, null]` could not have been shared across the `=>`. + // `['.', ['args'], 0]` could not have been shared across the `=>`. const outer = /** @type {Exp} */ ([ '=>', ['[]', []], - ['=>', ['[]', [['.', ['args'], 0, null]]], ['.', ['frame'], 0, null]], + ['=>', ['[]', [['.', ['args'], 0]]], ['.', ['frame'], 0]], ]) eq(['()', ['()', outer, ['[]', [7]]], noArgs], 7) // The frame operand is evaluated in the enclosing scope, so it sees // that scope's `['args']` — the one place a `=>` node reaches out. assertEq(vm({ frame: null, args: [11] })( - ['()', ['=>', ['[]', [['.', ['args'], 0, null]]], ['.', ['frame'], 0, null]], + ['()', ['=>', ['[]', [['.', ['args'], 0]]], ['.', ['frame'], 0]], noArgs]), 11) }, @@ -528,30 +528,30 @@ export const proof = { // `(g, x) => g(x)` const apply = /** @type {Exp} */ ([ '=>', ['[]', []], - ['()', ['.', ['args'], 0, null], ['[]', [['.', ['args'], 1, null]]]], + ['()', ['.', ['args'], 0], ['[]', [['.', ['args'], 1]]]], ]) eq(['()', apply, ['[]', [identity, 7]]], 7) // `x => y => x + y`, applied twice — the classic case the frame // exists for. const add = /** @type {Exp} */ ([ '=>', ['[]', []], - ['=>', ['[]', [['.', ['args'], 0, null]]], - ['+', ['.', ['frame'], 0, null], ['.', ['args'], 0, null]]], + ['=>', ['[]', [['.', ['args'], 0]]], + ['+', ['.', ['frame'], 0], ['.', ['args'], 0]]], ]) eq(['()', ['()', add, ['[]', [2]]], ['[]', [3]]], 5) }, throw: { // The index of a `?.` whose input is *not* nullish is evaluated, the // mirror of `optionRegion.skips`'s skipped operands. - evaluatedIndex: () => ev(['?.', ['{}', []], boomIndex, null]), + evaluatedIndex: () => ev(['?.', ['{}', []], boomIndex]), // ... and so are an optional call's arguments once its callee turns // out to be there. - evaluatedArgument: () => ev(['?.()', identity, boom, null]), + evaluatedArgument: () => ev(['?.()', identity, boom]), // `?.()` guards against a *nullish* callee, not against a // non-callable one: `1?.()` is the host `TypeError`, exactly as // `throw.callNonFunction` is for `()`. optionCallOnNonFunction: () => - ev(['?.()', ['.', ['{}', [[':', 'a', 1]]], 'a', null], noArgs, null]), + ev(['?.()', ['.', ['{}', [[':', 'a', 1]]], 'a'], noArgs]), // An array spread iterates its operand, so a non-iterable one throws // where the object form would have contributed nothing. arraySpreadOfNumber: () => ev(['[]', [['...', 1]]]), diff --git a/fjs/edag/module.f.mjs b/fjs/edag/module.f.mjs index 114378d95..8512a6e32 100644 --- a/fjs/edag/module.f.mjs +++ b/fjs/edag/module.f.mjs @@ -21,9 +21,11 @@ import { * chain grammar below claims each JS chain has exactly one spelling, and an * `open` tuple would let any node carry a trailing element nothing reads, * splitting one function into unboundedly many graphs. So do **not** wrap any - * of these in `open`. No operand of any node is optional either: a chain step - * that does no further work carries an explicit `null` continuation, never a - * missing position. + * of these in `open`. No operand of any node is optional either. A chain that + * ends says so by **arity**: the step or node is simply the shorter tuple, + * with no continuation position at all, and closedness by length is what + * keeps the two arities apart — which is why each such kind is an `or` of + * both rather than one tuple with an omittable tail. * * Do not call `parse(exp)` or rely on `validate(exp)` rejecting cycles * without reading `../rtti/todo/identity-aware-parse.md` first — @@ -204,11 +206,19 @@ export const index = or(numberCast, string, number) // // A lambda is **not** an `exp`: it reads the chain's current value implicitly, // so it has no operand to hold one, and it cannot be lifted out as a shared -// computation node — `['|.', 'b', null]` means nothing on its own, only as +// computation node — `['|.', 'b']` means nothing on its own, only as // the continuation of some chain node. That is the standing cost of this // shape: the receiver a step consumes cannot be shared, substituted, or // hashed. // +// **Ending a chain** is spelled by arity, not by a terminator value: a step +// or node that hands the chain on carries its continuation as a last operand, +// and one that ends it is the same tuple one element shorter. So every kind +// that can end is an `or` of its two closed arities, `null` is a primitive +// again rather than a chain terminator, and a trailing hole — `['.', a, 'b', ,]` +// — matches neither arm: the short one is bounded by length, and the long one +// has no `option` member to admit an absent one. +// // Four steps, each a transition on the two bits: // // ```text @@ -218,12 +228,14 @@ export const index = or(numberCast, string, number) // |!() clears P, clears O a call consumes it and closes the region // ``` // -// Every tag carries the `|` prefix, and that is a correctness requirement -// rather than a readability one. Unprefixed, `['()', f, null]` would be -// simultaneously a well-formed `call` — call `f` with `null` as its arguments -// — and a well-formed `optionLambda` — call the chain's value with `f` as its -// arguments, and stop. The two readings have the same length, so closedness -// cannot separate them; only disjoint vocabularies can. +// Every tag carries the `|` prefix, which keeps the step vocabulary disjoint +// from the node vocabulary: a tuple's tag alone says which grammar it belongs +// to. Unprefixed, `['()', f, k]` would read as a `call` — call `f` with `k` +// as its arguments — and as a step — call the chain's value with `f` as its +// arguments, then continue with `k`. Closedness bounds a tuple's length and +// says nothing about its tag, so no arity separates those readings; the +// prefix does, and does it without anyone having to prove that a continuation +// could never also be an expression. // // A production exists in a state exactly when moving that step into a nested // node would be **observable**, which is why the same step appears in one @@ -244,15 +256,22 @@ export const index = or(numberCast, string, number) * would not protect equally, so admitting them would only add a second * spelling. * + * Each production appears twice, once per **arity**: a step that hands the + * chain on carries its continuation, and one that ends the chain is a + * shorter tuple with no continuation position at all — see "Ending a chain" + * above. + * * @type {() => readonly['or', - * null, + * readonly['|()', typeof exp], * readonly['|()', typeof exp, typeof optionLambda], + * readonly['|.', typeof index], * readonly['|.', typeof index, typeof optionPropertyLambda], * ]} */ export const _optionLambda = () => (['or', - null, + /** @type {const} */ (['|()', exp]), /** @type {const} */ (['|()', exp, optionLambda]), + /** @type {const} */ (['|.', index]), /** @type {const} */ (['|.', index, optionPropertyLambda]), ]) @@ -267,31 +286,36 @@ export const optionLambda = _optionLambda * the region around it, and there is no fourth: * * ```js - * a?.b(...c) // ['?.', a, 'b', ['|()', c, null]] inherits the guard - * a?.b?.(...c) // ['?.', a, 'b', ['|?.()', c, null]] adds its own - * (a?.b)(...c) // ['?.', a, 'b', ['|!()', c, null]] escapes it + * a?.b(...c) // ['?.', a, 'b', ['|()', c]] inherits the guard + * a?.b?.(...c) // ['?.', a, 'b', ['|?.()', c]] adds its own + * (a?.b)(...c) // ['?.', a, 'b', ['|!()', c]] escapes it * ``` * * `|!()` is the one step a short-circuit does not skip: the parentheses ended * the region, so the `undefined` it produced is what gets called. `|!` pairs * only with `()` because only a call consumes a receiver — a close-then-access * `|!.` would just be a `dot` over the whole node, which nesting already - * spells. + * spells. It is also the one production with a single arity: closing the + * region is terminal, so it never carries a continuation. * * @type {() => readonly['or', - * null, + * readonly['|()', typeof exp], * readonly['|()', typeof exp, typeof optionLambda], + * readonly['|.', typeof index], * readonly['|.', typeof index, typeof optionPropertyLambda], + * readonly['|?.()', typeof exp], * readonly['|?.()', typeof exp, typeof optionLambda], - * readonly['|!()', typeof exp, null], + * readonly['|!()', typeof exp], * ]} */ export const _optionPropertyLambda = () => (['or', - null, + /** @type {const} */ (['|()', exp]), /** @type {const} */ (['|()', exp, optionLambda]), + /** @type {const} */ (['|.', index]), /** @type {const} */ (['|.', index, optionPropertyLambda]), + /** @type {const} */ (['|?.()', exp]), /** @type {const} */ (['|?.()', exp, optionLambda]), - /** @type {const} */ (['|!()', exp, null]), + /** @type {const} */ (['|!()', exp]), ]) /** @type {Phantom} */ @@ -303,20 +327,14 @@ export const optionPropertyLambda = _optionPropertyLambda * Only the two call steps are here, because only a call can use a receiver. * `|()` is terminal: with the receiver spent and no region to be inside, what * follows an `a.b(...c)` is an ordinary expression over an ordinary value, so - * it nests. `|?.()` continues, since it opens a region that then owns the - * rest of the chain. There is no `|.` production, which is what gives a plain - * property path exactly one spelling: `a.b.c` is nested `dot`s and nothing - * else. - * - * The terminal's third operand is a literal `null`, not the absence of one. - * Uniform arity is what keeps closedness able to tell it from `['|()', c, k]`: - * were the terminal two elements long, a continuation handed to a - * `propertyLambda` slot would be read as the terminal with the rest silently - * dropped. + * it nests — which is why it has only the shorter arity. `|?.()` continues, + * since it opens a region that then owns the rest of the chain, so it has + * both. There is no `|.` production, which is what gives a plain property + * path exactly one spelling: `a.b.c` is nested `dot`s and nothing else. */ export const propertyLambda = or( - null, - /** @type {const} */ (['|()', exp, null]), + /** @type {const} */ (['|()', exp]), + /** @type {const} */ (['|?.()', exp]), /** @type {const} */ (['|?.()', exp, optionLambda]), ) @@ -335,6 +353,9 @@ export const propertyLambda = or( * The last operand is one node evaluating to the complete argument array, * not a literal operand list: `f(a, b)` is `['()', f, ['[]', [a, b]]]`, * while spread `f(...xs)` is `['()', f, xs]`. + * + * One arity, unlike the three chain nodes below: `()` produces a bare value + * and so has no continuation operand to leave out. */ export const call = /** @type {const} */ (['()', exp, exp]) @@ -342,30 +363,33 @@ export const call = /** @type {const} */ (['()', exp, exp]) /** * ```js - * exp0.k // ['.', exp0, 'k', null] - * exp0[exp1] // ['.', exp0, ['Number', exp1], null] - * exp0.k(...exp2) // ['.', exp0, 'k', ['|()', exp2, null]] - * exp0.k?.(...exp2) // ['.', exp0, 'k', ['|?.()', exp2, null]] + * exp0.k // ['.', exp0, 'k'] + * exp0[exp1] // ['.', exp0, ['Number', exp1]] + * exp0.k(...exp2) // ['.', exp0, 'k', ['|()', exp2]] + * exp0.k?.(...exp2) // ['.', exp0, 'k', ['|?.()', exp2]] * ``` * * The naming operand is an `index`, not an `exp`, so a computed key is spelled - * `['Number', exp]`: `['.', a, ['args'], null]` does not validate. + * `['Number', exp]`: `['.', a, ['args']]` does not validate. * * Property access, owning whatever the receiver it produces is used for. The - * `null` continuation is the plain read — the receiver is dropped, as JS + * three-element arity is the plain read — the receiver is dropped, as JS * drops it — and the two call continuations are the only things that can use * it. */ -export const dot = /** @type {const} */ (['.', exp, index, propertyLambda]) +export const dot = or( + /** @type {const} */ (['.', exp, index]), + /** @type {const} */ (['.', exp, index, propertyLambda]), +) // Option Dot /** * ```js - * exp0?.k // ['?.', exp0, 'k', null] - * exp0?.[exp1] // ['?.', exp0, ['Number', exp1], null] - * exp0?.k.m // ['?.', exp0, 'k', ['|.', 'm', null]] - * (exp0?.k)(...exp2) // ['?.', exp0, 'k', ['|!()', exp2, null]] + * exp0?.k // ['?.', exp0, 'k'] + * exp0?.[exp1] // ['?.', exp0, ['Number', exp1]] + * exp0?.k.m // ['?.', exp0, 'k', ['|.', 'm']] + * (exp0?.k)(...exp2) // ['?.', exp0, 'k', ['|!()', exp2]] * ``` * * Optional property access, owning the rest of its optional region. If `exp0` @@ -376,18 +400,20 @@ export const dot = /** @type {const} */ (['.', exp, index, propertyLambda]) * therefore calls that `undefined`. * * Where the region ends is the grouping: `a?.b.c` is one node, - * `['?.', a, 'b', ['|.', 'c', null]]`, while `(a?.b).c` is a `dot` over a - * complete `['?.', a, 'b', null]` — and throws when `a` is nullish, as JS - * does. + * `['?.', a, 'b', ['|.', 'c']]`, while `(a?.b).c` is a `dot` over a + * complete `['?.', a, 'b']` — and throws when `a` is nullish, as JS does. */ -export const optionDot = /** @type {const} */ (['?.', exp, index, optionPropertyLambda]) +export const optionDot = or( + /** @type {const} */ (['?.', exp, index]), + /** @type {const} */ (['?.', exp, index, optionPropertyLambda]), +) // Option Call /** * ```js - * exp0?.(...exp1) // ['?.()', exp0, exp1, null] - * exp0?.(...exp1).k // ['?.()', exp0, exp1, ['|.', 'k', null]] + * exp0?.(...exp1) // ['?.()', exp0, exp1] + * exp0?.(...exp1).k // ['?.()', exp0, exp1, ['|.', 'k']] * ``` * * Optional call, owning the rest of its optional region the way `?.` does. @@ -395,7 +421,10 @@ export const optionDot = /** @type {const} */ (['?.', exp, index, optionProperty * — `a.b?.(...c)` is a `dot` with a `|?.()` continuation, not this. If `exp0` * is nullish the arguments are not evaluated and the region short-circuits. */ -export const optionCall = /** @type {const} */ (['?.()', exp, exp, optionLambda]) +export const optionCall = or( + /** @type {const} */ (['?.()', exp, exp]), + /** @type {const} */ (['?.()', exp, exp, optionLambda]), +) // Comma diff --git a/fjs/edag/proof.f.mjs b/fjs/edag/proof.f.mjs index b41b23e50..9bf2b8867 100644 --- a/fjs/edag/proof.f.mjs +++ b/fjs/edag/proof.f.mjs @@ -270,31 +270,42 @@ export const proof = { assertNoMatch(v(['.', 'a', 'b', ['|?.()', 1, null, 'extra']])) assertNoMatch(v(['?.', 'a', 'b', ['|.', 'c', null, 'extra']])) assertNoMatch(v(['?.', 'a', 'b', ['|!()', 1, null, 'extra']])) - assertNoMatch(v(['.', 'a', ['Number', 1, 'extra'], null])) + assertNoMatch(v(['.', 'a', ['Number', 1, 'extra']])) }, }, dot: { ok: () => { - assertOk(v(['.', 'a', 'b', null])) - assertOk(v(['.', ['[]', [1, 2]], 0, null])) + assertOk(v(['.', 'a', 'b'])) + assertOk(v(['.', ['[]', [1, 2]], 0])) // `index`'s three accepted shapes, pinned explicitly: string, // number (above), and a `numberCast` (below) — not `boolean` // (see `error`). - assertOk(v(['.', 'a', ['Number', 1], null])) + assertOk(v(['.', 'a', ['Number', 1]])) }, // `index` — string, number, or `numberCast` — never admitted // `undefined`, so a missing index has always been a real error. - missingIndexIsError: () => assertNoMatch(v(['.', 'a', null, null])), - // The continuation is not optional. `null` says "the receiver is - // dropped here"; a missing position says nothing, and reads as - // `undefined`, which no lambda admits. - missingTailIsError: () => assertNoMatch(v(['.', 'a', 'b'])), + missingIndexIsError: () => assertNoMatch(v(['.', 'a', null])), + // Ending the chain is the *shorter* arity (`ok` above), so the + // terminator values are gone: `null` is a primitive again and has no + // reading in a continuation position, and a present `undefined` never + // had one. + terminatorTailIsError: () => { + assertNoMatch(v(['.', 'a', 'b', null])) + assertNoMatch(v(['.', 'a', 'b', undefined])) + }, + // A trailing **hole** is not the short arity: the three-element arm + // is bounded by length and the four-element arm has no `option` + // member, so a length-4 value with nothing at index 3 matches + // neither. `concat` builds one without a hole literal, which + // FunctionalScript does not have. + trailingHoleIsError: () => + assertNoMatch(v(['.', 'a', 'b'].concat(new Array(1)))), error: () => { assertNoMatch(v(['x', 'a', 'b', null])) - assertNoMatch(v(['.', {}, 'b', null])) + assertNoMatch(v(['.', {}, 'b'])) // `index` excludes `boolean` on purpose — not narrowed to just // `string`/`number` by accident. - assertNoMatch(v(['.', 'a', true, null])) + assertNoMatch(v(['.', 'a', true])) }, }, // The chain continuations. There are three because a chain carries two @@ -307,96 +318,103 @@ export const proof = { // because only a call spends a receiver; `|()` is terminal and // `|?.()` opens a region that owns the rest of the chain. propertyLambda: () => { - assertOk(vPropertyLambda(null)) - assertOk(vPropertyLambda(['|()', 1, null])) - assertOk(vPropertyLambda(['|?.()', 1, null])) - assertOk(vPropertyLambda(['|?.()', 1, ['|.', 'c', null]])) + assertOk(vPropertyLambda(['|()', 1])) + assertOk(vPropertyLambda(['|?.()', 1])) + assertOk(vPropertyLambda(['|?.()', 1, ['|.', 'c']])) // No `|.`: a property step here would waste the receiver with no // region to keep it in, so `a.b.c` nests `.` nodes instead. That // absence is what gives a plain property path one spelling. - assertNoMatch(vPropertyLambda(['|.', 'c', null])) + assertNoMatch(vPropertyLambda(['|.', 'c'])) // No `|!()`: there is no open region for it to close. - assertNoMatch(vPropertyLambda(['|!()', 1, null])) + assertNoMatch(vPropertyLambda(['|!()', 1])) }, // `optionLambda` — a plain value inside a region. A call stays in the // region (it must, or the region would not cover it) and a property // step hands a receiver on within it. optionLambda: () => { - assertOk(vOptionLambda(null)) - assertOk(vOptionLambda(['|()', 1, null])) - assertOk(vOptionLambda(['|.', 'c', null])) - assertOk(vOptionLambda(['|.', 'c', ['|!()', 1, null]])) + assertOk(vOptionLambda(['|()', 1])) + assertOk(vOptionLambda(['|.', 'c'])) + assertOk(vOptionLambda(['|.', 'c', ['|!()', 1]])) // Neither `|?.()` nor `|!()` is here: with the receiver already // spent, guarding or closing at this point protects nothing a // nested node would not protect equally. - assertNoMatch(vOptionLambda(['|?.()', 1, null])) - assertNoMatch(vOptionLambda(['|!()', 1, null])) + assertNoMatch(vOptionLambda(['|?.()', 1])) + assertNoMatch(vOptionLambda(['|!()', 1])) }, // `optionPropertyLambda` — both bits live, so every production is // here: the three ways a call can relate to its region, plus the // property step the region will not let leave. optionPropertyLambda: () => { - assertOk(vOptionPropertyLambda(null)) - assertOk(vOptionPropertyLambda(['|()', 1, null])) - assertOk(vOptionPropertyLambda(['|.', 'c', null])) - assertOk(vOptionPropertyLambda(['|?.()', 1, null])) - assertOk(vOptionPropertyLambda(['|!()', 1, null])) + assertOk(vOptionPropertyLambda(['|()', 1])) + assertOk(vOptionPropertyLambda(['|.', 'c'])) + assertOk(vOptionPropertyLambda(['|?.()', 1])) + assertOk(vOptionPropertyLambda(['|!()', 1])) // `|.` hands the region back to this same state, so every // production above is reachable one property step further in — // `a?.b.c?.(...d)` is the guarded call through a `|.`. - assertOk(vOptionPropertyLambda(['|.', 'c', ['|?.()', 1, null]])) - assertOk(vOptionPropertyLambda(['|.', 'c', ['|.', 'd', null]])) + assertOk(vOptionPropertyLambda(['|.', 'c', ['|?.()', 1]])) + assertOk(vOptionPropertyLambda(['|.', 'c', ['|.', 'd']])) }, // `|.` takes an `index` and the call steps take an `exp`, the same // operand schemas the nodes use — so a general `exp` in a naming // position is rejected where it is accepted in an argument one. operandSchemas: () => { - assertOk(vOptionLambda(['|.', 0, null])) - assertOk(vOptionLambda(['|.', ['Number', 1], null])) - assertNoMatch(vOptionLambda(['|.', ['[]', []], null])) - assertOk(vOptionLambda(['|()', ['[]', [1, 2]], null])) + assertOk(vOptionLambda(['|.', 0])) + assertOk(vOptionLambda(['|.', ['Number', 1]])) + assertNoMatch(vOptionLambda(['|.', ['[]', []]])) + assertOk(vOptionLambda(['|()', ['[]', [1, 2]]])) }, // A lambda only means anything as the continuation of a chain node: // it takes its input implicitly, so on its own it is not an `exp` and // cannot be lifted out as a shared node. The `|` prefix is what makes // that statable — see `tagsAreDisjoint`. notAnExp: () => { - assertNoMatch(v(['|.', 'b', null])) - assertNoMatch(v(['|()', 1, null])) - assertNoMatch(v(['|?.()', 1, null])) - assertNoMatch(v(['|!()', 1, null])) - }, - // The prefix is a correctness requirement, not a readability one. - // Unprefixed, `['()', f, null]` would be both a `call` — call `f` - // with `null` as its arguments — and an `optionLambda` — call the - // chain's value with `f` as its arguments, and stop. Equal length, so - // closedness could not have separated them — it bounds a tuple's - // length and says nothing about its tag; only disjoint vocabularies - // can, and these two assertions are that disjointness. + assertNoMatch(v(['|.', 'b'])) + assertNoMatch(v(['|()', 1])) + assertNoMatch(v(['|?.()', 1])) + assertNoMatch(v(['|!()', 1])) + }, + // The prefix keeps the step vocabulary disjoint from the node one, + // so a tuple's tag alone says which grammar it belongs to. Unprefixed, + // `['()', f, k]` would read as a `call` — call `f` with `k` as its + // arguments — and as a step — call the chain's value with `f` as its + // arguments, then continue with `k`. Closedness bounds a tuple's + // length and says nothing about its tag, so no arity separates those + // readings; these assertions are the disjointness that does. tagsAreDisjoint: () => { assertOk(v(['()', 'f', null])) assertNoMatch(vOptionLambda(['()', 'f', null])) - assertNoMatch(v(['|()', 'f', null])) - }, - // Uniform arity is the other half. `propertyLambda`'s `|()` is - // terminal, and it says so with an explicit `null` rather than by - // being one element shorter: a two-element terminal handed a real - // continuation would validate with the rest silently dropped. - terminalsAreExplicit: () => { - assertNoMatch(vPropertyLambda(['|()', 1])) - assertNoMatch(vPropertyLambda(['|()', 1, ['|.', 'c', null]])) - assertNoMatch(vOptionPropertyLambda(['|!()', 1])) - assertNoMatch(vOptionPropertyLambda(['|!()', 1, ['|.', 'c', null]])) - }, - missingTailIsError: () => { - assertNoMatch(vOptionPropertyLambda(['|.', 'c'])) - assertNoMatch(vOptionPropertyLambda(['|()', 1])) - assertNoMatch(vOptionPropertyLambda(['|?.()', 1])) + assertNoMatch(v(['|()', 'f'])) + }, + // A terminal has one arity, and closedness by length is what keeps + // the rest from being smuggled past it: `propertyLambda`'s `|()` + // spends the receiver and exits, `optionPropertyLambda`'s `|!()` + // closes the region, and neither has a three-element arm to hold a + // continuation. This is the case the old explicit `null` existed to + // guard, and length now answers it. + terminalsTakeNoContinuation: () => { + assertNoMatch(vPropertyLambda(['|()', 1, ['|.', 'c']])) + assertNoMatch(vOptionPropertyLambda(['|!()', 1, ['|.', 'c']])) + // ...including the old spelling, whose `null` is simply a third + // element the terminal does not declare. + assertNoMatch(vPropertyLambda(['|()', 1, null])) + assertNoMatch(vOptionPropertyLambda(['|!()', 1, null])) + }, + // The steps that *can* continue end by being one element shorter, + // never by carrying a terminator, and never by leaving a hole where + // the continuation would go. + endingIsTheShorterArity: () => { + assertOk(vOptionPropertyLambda(['|.', 'c'])) + assertOk(vOptionPropertyLambda(['|()', 1])) + assertOk(vOptionPropertyLambda(['|?.()', 1])) + assertNoMatch(vOptionPropertyLambda(['|.', 'c', null])) + assertNoMatch(vOptionPropertyLambda(['|()', 1, undefined])) + assertNoMatch(vOptionPropertyLambda(['|()', 1].concat(new Array(1)))) }, // Each tag is a real constraint, not a stand-in for `string`: a node // tag in a step position is rejected, and so is an unknown one. unknownOpIsRejected: () => { - assertNoMatch(vOptionPropertyLambda(['.', 'b', null])) + assertNoMatch(vOptionPropertyLambda(['.', 'b'])) assertNoMatch(vOptionPropertyLambda(['|.z', 'b', null])) assertNoMatch(vOptionPropertyLambda('xyz')) }, @@ -404,7 +422,7 @@ export const proof = { call: { ok: () => { assertOk(v(['()', 'f', ['[]', []]])) - assertOk(v(['()', ['.', 'o', 'k', null], 1])) // (0, o.k)(...args) + assertOk(v(['()', ['.', 'o', 'k'], 1])) // (0, o.k)(...args) assertOk(v(['()', 'f', ['[]', [1, 2]]])) }, // A missing argument operand reads as `undefined` — an error, same @@ -417,30 +435,40 @@ export const proof = { }, optionDot: { ok: () => { - assertOk(v(['?.', 'a', 'b', null])) - assertOk(v(['?.', 'a', ['Number', 1], null])) - assertOk(v(['?.', 'a', 'b', ['|.', 'c', null]])) + assertOk(v(['?.', 'a', 'b'])) + assertOk(v(['?.', 'a', ['Number', 1]])) + assertOk(v(['?.', 'a', 'b', ['|.', 'c']])) }, // Same three `index` shapes as `.`, `boolean` excluded the same way. error: () => { - assertNoMatch(v(['?.', 'a', true, null])) + assertNoMatch(v(['?.', 'a', true])) assertNoMatch(v(['?.z', 'a', 'b', null])) }, - // The continuation is required — `null` says "the optional region - // ends here", a missing position says nothing. - missingTailIsError: () => assertNoMatch(v(['?.', 'a', 'b'])), + // As on `.`: the region ends at the shorter arity, so a terminator + // value or a trailing hole in the continuation position is an error. + terminatorTailIsError: () => { + assertNoMatch(v(['?.', 'a', 'b', null])) + assertNoMatch(v(['?.', 'a', 'b', undefined])) + }, + trailingHoleIsError: () => + assertNoMatch(v(['?.', 'a', 'b'].concat(new Array(1)))), }, optionCall: { ok: () => { - assertOk(v(['?.()', 'f', 1, null])) - assertOk(v(['?.()', 'f', ['[]', [1]], ['|()', 2, null]])) + assertOk(v(['?.()', 'f', 1])) + assertOk(v(['?.()', 'f', ['[]', [1]], ['|()', 2]])) }, // One continuation, not two: the callee is an ordinary expression, so - // there is no pre-call chain for this node to own. - missingTailIsError: () => { - assertNoMatch(v(['?.()', 'f', 1])) - assertNoMatch(v(['?.()', 'f'])) - }, + // there is no pre-call chain for this node to own. The *arguments* + // operand is still required — only the continuation is what the + // shorter arity leaves out. + missingArgsIsError: () => assertNoMatch(v(['?.()', 'f'])), + terminatorTailIsError: () => { + assertNoMatch(v(['?.()', 'f', 1, null])) + assertNoMatch(v(['?.()', 'f', 1, undefined])) + }, + trailingHoleIsError: () => + assertNoMatch(v(['?.()', 'f', 1].concat(new Array(1)))), error: () => assertNoMatch(v(['?.()', 'f', [], 1, []])), }, // One entry per JS spelling whose grouping or hidden control flow the @@ -454,58 +482,58 @@ export const proof = { // that node owns. Reaching the call through a complete node instead // is the detached spelling, and a different graph. receiver: () => { - assertOk(v(['.', 'a', 'b', ['|()', 'args', null]])) // a.b(...args) - assertOk(v(['()', ['.', 'a', 'b', null], 'args'])) // (0, a.b)(...args) - assertOk(v(['.', 'a', 'b', ['|?.()', 'args', null]])) // a.b?.(...args) - assertOk(v(['?.()', 'a', 'args', null])) // a?.(...args) + assertOk(v(['.', 'a', 'b', ['|()', 'args']])) // a.b(...args) + assertOk(v(['()', ['.', 'a', 'b'], 'args'])) // (0, a.b)(...args) + assertOk(v(['.', 'a', 'b', ['|?.()', 'args']])) // a.b?.(...args) + assertOk(v(['?.()', 'a', 'args'])) // a?.(...args) // (a?.(...args))(...args2) - assertOk(v(['()', ['?.()', 'a', 'args', null], 'args2'])) + assertOk(v(['()', ['?.()', 'a', 'args'], 'args2'])) }, // A plain property path nests, because `propertyLambda` has no `|.` // production — so `a.b.c` has exactly one spelling and the dead-prefix // rule needs no lowering pass to hold. propertyPath: () => { - assertOk(v(['.', ['.', 'a', 'b', null], 'c', null])) // a.b.c + assertOk(v(['.', ['.', 'a', 'b'], 'c'])) // a.b.c // a.b(...args).c — the inner call is the inner node's business. - assertOk(v(['.', ['.', 'a', 'b', ['|()', 'args', null]], 'c', null])) + assertOk(v(['.', ['.', 'a', 'b', ['|()', 'args']], 'c'])) }, // An optional region is one continuation chain, however long, and // grouping is what ends it: `a?.b.c` skips `.c` on a nullish `a`, // `(a?.b).c` throws there — one node against two. optionalRegion: () => { - assertOk(v(['?.', 'a', 'b', null])) // a?.b - assertOk(v(['?.', 'a', 'b', ['|.', 'c', null]])) // a?.b.c - assertOk(v(['.', ['?.', 'a', 'b', null], 'c', null])) // (a?.b).c + assertOk(v(['?.', 'a', 'b'])) // a?.b + assertOk(v(['?.', 'a', 'b', ['|.', 'c']])) // a?.b.c + assertOk(v(['.', ['?.', 'a', 'b'], 'c'])) // (a?.b).c // a?.b.c(...args) — the call is inside the region. - assertOk(v(['?.', 'a', 'b', ['|.', 'c', ['|()', 'args', null]]])) + assertOk(v(['?.', 'a', 'b', ['|.', 'c', ['|()', 'args']]])) // (a?.b).c(...args) — the parens ended it, so a `.` node owns the // call and `a?.b` is a complete node under it. - assertOk(v(['.', ['?.', 'a', 'b', null], 'c', ['|()', 'args', null]])) + assertOk(v(['.', ['?.', 'a', 'b'], 'c', ['|()', 'args']])) // a?.b(...args).c(...args2) — one region across two calls. assertOk(v(['?.', 'a', 'b', - ['|()', 'args', ['|.', 'c', ['|()', 'args2', null]]]])) + ['|()', 'args', ['|.', 'c', ['|()', 'args2']]]])) // a?.(...args).c and a?.(...args)(...args2) - assertOk(v(['?.()', 'a', 'args', ['|.', 'c', null]])) - assertOk(v(['?.()', 'a', 'args', ['|()', 'args2', null]])) + assertOk(v(['?.()', 'a', 'args', ['|.', 'c']])) + assertOk(v(['?.()', 'a', 'args', ['|()', 'args2']])) }, // The three ways a call can relate to the region around it — the // complete taxonomy, and the reason `|!()` is a tag of its own. callsAgainstTheRegion: () => { - assertOk(v(['?.', 'a', 'b', ['|()', 'args', null]])) // a?.b(...args) - assertOk(v(['?.', 'a', 'b', ['|?.()', 'args', null]])) // a?.b?.(...args) - assertOk(v(['?.', 'a', 'b', ['|!()', 'args', null]])) // (a?.b)(...args) + assertOk(v(['?.', 'a', 'b', ['|()', 'args']])) // a?.b(...args) + assertOk(v(['?.', 'a', 'b', ['|?.()', 'args']])) // a?.b?.(...args) + assertOk(v(['?.', 'a', 'b', ['|!()', 'args']])) // (a?.b)(...args) // (a?.b.c)(...args) — the region closes after a property step, // which is the same `|!()` one step further in. - assertOk(v(['?.', 'a', 'b', ['|.', 'c', ['|!()', 'args', null]]])) + assertOk(v(['?.', 'a', 'b', ['|.', 'c', ['|!()', 'args']]])) // a?.b.c?.(...args) — and so is the guarded call. - assertOk(v(['?.', 'a', 'b', ['|.', 'c', ['|?.()', 'args', null]]])) + assertOk(v(['?.', 'a', 'b', ['|.', 'c', ['|?.()', 'args']]])) }, // The operands an optional node skips on its nullish branch have to // be operands *of* that node, which is what makes `k`/`a` // observably unevaluated: `a?.[k]` and `f?.(...a)`. skippedOperands: () => { - assertOk(v(['?.', 'a', ['Number', 'k'], null])) // a?.[k] - assertOk(v(['?.()', 'f', 'a', null])) // f?.(...a) + assertOk(v(['?.', 'a', ['Number', 'k']])) // a?.[k] + assertOk(v(['?.()', 'f', 'a'])) // f?.(...a) }, }, // The four duplicate families a flat step array admitted are not @@ -515,27 +543,27 @@ export const proof = { // `a?.b?.c` — no lambda has a `?.` production at all; `?.` is only // ever a node tag, so a guarded property access always starts a node. optionalPropertyStep: () => { - assertNoMatch(v(['?.', 'a', 'b', ['|?.', 'c', null]])) - assertOk(v(['?.', ['?.', 'a', 'b', null], 'c', null])) // the spelling + assertNoMatch(v(['?.', 'a', 'b', ['|?.', 'c']])) + assertOk(v(['?.', ['?.', 'a', 'b'], 'c'])) // the spelling }, // `a.b(...c)?.d` — `propertyLambda`'s `|()` is terminal, so the chain // exits and what follows is an ordinary node over an ordinary value. callTerminatesPropertyLambda: () => { - assertNoMatch(v(['.', 'a', 'b', ['|()', 'c', ['|.', 'd', null]]])) - assertOk(v(['?.', ['.', 'a', 'b', ['|()', 'c', null]], 'd', null])) + assertNoMatch(v(['.', 'a', 'b', ['|()', 'c', ['|.', 'd']]])) + assertOk(v(['?.', ['.', 'a', 'b', ['|()', 'c']], 'd'])) }, // `(a?.(...b))(...c)` — `optionLambda` has no `|!()`, since with the // receiver already spent there is nothing for the close to keep. The // outer call is a plain `()` over a complete `?.()` node. closeWithoutReceiver: () => { - assertNoMatch(v(['?.()', 'a', 'b', ['|!()', 'c', null]])) - assertOk(v(['()', ['?.()', 'a', 'b', null], 'c'])) + assertNoMatch(v(['?.()', 'a', 'b', ['|!()', 'c']])) + assertOk(v(['()', ['?.()', 'a', 'b'], 'c'])) }, // `a?.b(...c)?.d` — `optionLambda` has no guarded step either, so the // guarded access after the call starts its own node. guardedStepAfterCall: () => { - assertNoMatch(v(['?.', 'a', 'b', ['|()', 'c', ['|?.()', 'd', null]]])) - assertOk(v(['?.()', ['?.', 'a', 'b', ['|()', 'c', null]], 'd', null])) + assertNoMatch(v(['?.', 'a', 'b', ['|()', 'c', ['|?.()', 'd']]])) + assertOk(v(['?.()', ['?.', 'a', 'b', ['|()', 'c']], 'd'])) }, }, op0: { @@ -630,17 +658,17 @@ export const proof = { // `this`, and parentheses around the reference do not break that — // only detaching the value does (`throw.detachedReceiver`). It holds // across an optional link too, which is why `(a?.b)(d)` is a `?.` - // node with a `|!()` continuation, `['?.', a, 'b', ['|!()', d, null]]`, - // rather than a `()` over a complete `['?.', a, 'b', null]`: the + // node with a `|!()` continuation, `['?.', a, 'b', ['|!()', d]]`, + // rather than a `()` over a complete `['?.', a, 'b']`: the // latter would produce an ordinary value and lose the receiver. receiver: () => { const a = [42] assertEq(a.at(0), 42) assertEq((a.at)(0), 42) - assertEq((a?.at)(0), 42) // ['?.', a, 'at', ['|!()', …, null]] - assertEq((a?.at)?.(0), 42) // ['?.', a, 'at', ['|?.()', …, null]] - assertEq(a.at?.(0), 42) // ['.', a, 'at', ['|?.()', …, null]] - assertEq(a?.at?.(0), 42) // ['?.', a, 'at', ['|?.()', …, null]] + assertEq((a?.at)(0), 42) // ['?.', a, 'at', ['|!()', …]] + assertEq((a?.at)?.(0), 42) // ['?.', a, 'at', ['|?.()', …]] + assertEq(a.at?.(0), 42) // ['.', a, 'at', ['|?.()', …]] + assertEq(a?.at?.(0), 42) // ['?.', a, 'at', ['|?.()', …]] }, // Short-circuit: a nullish link skips the rest of its chain, and // grouping is what ends that chain — `u?.at.name` is `undefined` @@ -650,7 +678,7 @@ export const proof = { /** @type {any} */ const u = undefined assertEq(u?.at, undefined) - assertEq(u?.at.name, undefined) // ['?.', u, 'at', ['|.', 'name', null]] + assertEq(u?.at.name, undefined) // ['?.', u, 'at', ['|.', 'name']] assertEq(u?.at?.(0), undefined) // The operands on the skipped branch are never evaluated: an // optional property's index, and an optional call's arguments. @@ -696,7 +724,7 @@ export const proof = { // `|!()` terms, JavaScriptCore (so `bun test`) carries the // short-circuit through the parentheses and answers `undefined` where // V8 throws, so asserting either answer would redden a runner. The - // node is unaffected — `['?.', u, 'at', ['|!()', …, null]]` means the + // node is unaffected — `['?.', u, 'at', ['|!()', …]]` means the // throwing reading — and `optionRegion.throw.closeStepOnUndefined` in // `./amnesia/proof.f.mjs` pins it by evaluating the node, which is // the only oracle that works on every runner. See "Chains" in @@ -759,7 +787,7 @@ export const proof = { const value = /** @type {const} */ (['.', ['()', 'f', ['args']], 'k', - ['|()', ['[]', [['.', 'obj', 'a', null]]], null], + ['|()', ['[]', [['.', 'obj', 'a']]]], ]) assertOk(v(value)) }, diff --git a/fjs/edag/todo/option-terminated-lambdas.md b/fjs/edag/todo/option-terminated-lambdas.md deleted file mode 100644 index 47e2eac4f..000000000 --- a/fjs/edag/todo/option-terminated-lambdas.md +++ /dev/null @@ -1,276 +0,0 @@ -# End chain lambdas by absence, not `null` - -**Priority:** P3 -**Status:** open - -## Problem - -The chain grammar spells "the chain ends here" as a literal `null`, in two -roles: as a member of all three lambda unions (`['.', a, 'b', null]` is a -plain read), and as the terminals' stated third operand — `['|()', exp, null]` -in `propertyLambda`, `['|!()', exp, null]` in `optionPropertyLambda`. That -gives `null` double duty in a graph — primitive value *and* chain terminator — -and puts a fourth element on every plain property access, the first cost named -in "The cost" of [`../README.md`](../README.md). - -rtti has a schema built to mean exactly "the member that is not there": -[`option`](../../rtti/module.f.mjs). A continuation is always the last operand -of a closed tuple, which is precisely the position where absence is observable -and where `TupleTs` renders it as an exact optional element. So the chain -could end by the operand *not being there*: `['.', a, 'b']`, `['|()', c]`, -`['|!()', c]`. - -The module's recorded argument against this is stale. "Terminals state their -`null` … were the terminal two elements long, a continuation handed to a -`propertyLambda` slot would be read as the terminal with the rest silently -dropped" (`../module.f.mjs`, `../README.md`) was written when the chain -grammar landed (53077ae, 2026-08-26) — **against open-by-default tuples**, -where it was true. Bare tuples became closed by length one day later -(7852819, 2026-08-27), whose design issue was titled "closed containers by -default, then `option` as omission" (930fa65, #1725) — this issue is the -second half of that plan. Under the closed model a two-element terminal handed -a continuation is rejected by length, not truncated. - -## Investigation (2026-08-28, verified) - -### Runtime, against the real `validate` - -With a `dot` whose fourth position is `or(option, …)` and step schemas ending -by absence: - -| value | result | why | -|---|---|---| -| `['.', a, 'b']` | ok | absent continuation — the plain read | -| `['.', a, 'b', ['\|()', c]]` | ok | two-element terminal step | -| `['.', a, 'b', ['\|()', c, ['\|()', d]]]` | error | the "silent drop" fear: closedness answers by length and **rejects** | -| `['.', a, 'b', undefined]` | error | absence is not a spelling of `undefined` | -| `['.', a, 'b', null]` | error | `null` leaves the chain vocabulary entirely | -| `['.', a, 'b', ['\|()', c], 'junk']` | error | closed node tuple, as today | -| `['.', a, 'b', ['\|?.()', c, ['\|.', 'd']]]` | ok | recursion through the thunks unaffected | - -### Type level, under the repository's flags - -Compiles clean (including `exactOptionalPropertyTypes`), with each negative -row above a genuine type error: - -- Hand-written types use optional trailing tuple elements, which `Ts` renders - **exactly** (`_OptionalTail` in [`../../rtti/ts/proof.f.mjs`](../../rtti/ts/proof.f.mjs)): - - ```ts - type PropertyLambda = - | readonly['|()', Exp] // terminal: genuinely shorter - | readonly['|?.()', Exp, OptionLambda?] - type OptionLambda = - | readonly['|()', Exp, OptionLambda?] - | readonly['|.', Index, OptionPropertyLambda?] - type OptionPropertyLambda = - | readonly['|()', Exp, OptionLambda?] - | readonly['|.', Index, OptionPropertyLambda?] - | readonly['|?.()', Exp, OptionLambda?] - | readonly['|!()', Exp] // terminal - type Dot = readonly['.', Exp, Index, PropertyLambda?] - ``` - -- The recursive thunks' roots now admit absence, so their `Phantom` - annotations must carry the flag in the wrapper — - `Phantom>` — pinned with - `CheckRaw` **in addition to** `Check3`, which cannot see the flag (the - public `Ts` strips absence from both sides). See the `Ts` JSDoc in - [`../../rtti/ts/types.ts`](../../rtti/ts/types.ts). This would be the first - real consumer of the `AbsentOr`/`CheckRaw` pattern — currently documented - with zero users — and it was verified to compile with the mutual recursion - above. `propertyLambda` is not phantom-wrapped, so it needs no wrapper: - `_AdmitsAbsence` walks its `or` directly. - -Both findings above verify the `option` spelling — the alternative the hole -question below ends up rejecting. They stay as the record of what was -established; the chosen arity-split spelling needs none of that machinery, -its hand-written types being plain unions of exact tuples, the pattern the -schema already uses today. - -### What is gained - -- `null` in a graph means one thing again: the primitive value. -- Every plain property access and every chain end drops one element — fewer - elements to store and hash. -- "The chain ends" is spelled by the operand not being there — a shorter - closed tuple. - -### Trailing holes must be rejected in the same change - -`option` admits a hole as absence, so the sparse `['.', a, 'b', ,]` — length -4, index 3 a hole — **also validates** (verified), a second spelling with a -second hash for the same function, where today's required-`null` schema -rejects every hole. FunctionalScript cannot produce it — "Two adjacent commas -are not an elision: an array has no holes" -([`../../../spec/README.md`](../../../spec/README.md), Arrays) — and a hole -even *evaluates* identically to absence (reading it yields `undefined`), so -the leak is canonicality-only. It is still a validation regression against -today's schema, and a regression may not be deferred behind a todo -([`AGENTS.md`](../../../AGENTS.md) §5, "Merge the knowledge"): the migration -does not land unless the same change keeps `validate(exp)` rejecting a -trailing hole, pinned in the proofs. - -**The chosen mechanism is arity-split unions, no `option` at all** (verified -against the real `validate`, no rtti change needed). Each node or step whose -continuation may end is a union of its two closed arities — -`or(['.', exp, index], ['.', exp, index, propertyLambda])`, and likewise per -step — so absence is spelled by the shorter tuple and a hole matches neither -arm: the 3-arity arm rejects length 4, the 4-arity arm has no `option` and -rejects the absent member. `['.', a, 'b', ,]` and `['|()', c, ,]` both -reject; every acceptance row in the table above is unchanged. Costs: each -such kind doubles its union arms, the shared prefix is written twice, and -the `AbsentOr`/`CheckRaw` machinery drops out — hand-written types are plain -unions of exact tuples, no optional elements. - -One boundary of the gate, measured both below and past `length`: a -**prototype-supplied index** is rtti's host question, not this migration's, -and the answers are symmetric between the spellings. The tuple readers -decide presence by HasProperty and read a below-`length` index through the -prototype, held to the schema (`constContainerValidate` in -[`../../rtti/validate/module.f.mjs`](../../rtti/validate/module.f.mjs)), -and never answer an index at or past `length` — both stated, with the -`Array.prototype[10] = 99` example, in "Beyond `length`" in -[`../../rtti/README.md`](../../rtti/README.md), as a caveat that "applies -to `array`, `record` and every container schema alike". So a polluted -`Array.prototype` reaches both spellings, and only each one's own -vocabulary decides which values flip: behind `['.', a, 'b', ,]`'s own -length-4 hole, an inherited `['|()', c, null]` validates under **today's** -schema and rejects under the split one, an inherited `['|()', c]` exactly -the reverse; past the end, the split 3-arity arm accepts a length-3 -`['.', a, 'b']` whatever `Array.prototype[3]` holds — the unanswered -region every bare tuple in the repository already has — while today's -schema accepts the same length-3 value the moment `Array.prototype[3]` is -its own `null`. Neither spelling is pollution-proof, neither ever was, and -under a pristine prototype — the only host DJS admits — both reject every -hole and every spelling has exactly one length. On the executor the -unanswered region is covered by the read pattern: -[amnesia](../amnesia/module.f.mjs)'s three lambda walkers **destructure** -(`const [o, e, cont] = k`), and destructuring goes through the array -iterator, which stops at `length`, so a prototype-supplied index past a -short tuple's end is never read — measured: with -`Array.prototype[3] = 'junk'`, the destructured fourth slot of a length-3 -node is `undefined` and the chain ends, while a direct `node[3]` would -read `'junk'`. The one walker that does **not** is `skip`, which reads -`k[0]`/`k[1]`/`k[2]` directly — safe today only because every step carries -an own third member; after the migration a short step's `k[2]` would read -the prototype, and a polluted `Array.prototype[2]` could hand `skip` an -inherited continuation where the step's own trailing `null` masks that -index today. So `skip` joins the destructuring pattern **in the same -change** — the amnesia task below says so. That is where executor-side -host-hardening **ends** for this migration, deliberately. Under a hostile -host every read style has its own attack — a direct index without a length -check reads the prototype, destructuring dispatches an own overridden -`Symbol.iterator`, which can fabricate elements — and the three lambda -walkers destructure **today**, so an iterator-hostile step already -misleads the current evaluator identically; the migration changes nothing -about that class, and `skip` joining the module's one uniform pattern adds -no sensitivity the module does not already have. Amnesia's own README -scopes it — "It is not a VM for FunctionalScript, and nothing that matters -should run on it" — and [`../README.md`](../README.md) adds the intent, -"deliberately not a VM to run FunctionalScript on": its guarantees assume -a DJS value on a pristine host, where every read style coincides. -The gate's claim is therefore about the value's **own** members under -rtti's stated reading model; hermetic reads for hostile hosts beyond that -are rtti's tracked question -([`hostile-accessor-hermetic-read-path`](../../rtti/todo/hostile-accessor-hermetic-read-path.md)), -and hardening an executor against a hostile host is a VM concern, out of -scope for amnesia by its own charter — not an EDAG-boundary duplicate. - -**The rejected alternative** — keep the `option` spelling and first land an -rtti rule that absence in a tuple is the array ending before the position, -never a hole (the validators' absent branch requiring the index at or past -`value.length`) — was weighed across three review rounds of -[#1755](https://github.com/functionalscript/functionalscript/pull/1755) and -rejected because its prerequisite grows into an open-ended redesign of -rtti's canonical algebra, not one condition. The rule aligns the readers' -array domain with DJS (no sparse arrays — the direction of -[`data-validate-admits-non-djs-values`](../../rtti/todo/data-validate-admits-non-djs-values.md)), -but it cascades: it reverses documented reader and printer behavior -(`option`'s "position 0 may be a hole"; `_InteriorTs`'s "what reading a hole -gives"); it collapses `[option]` into `[]` — one set once `new Array(1)` is -excluded — while `trimPrefix` in -[`../../rtti/data/module.f.mjs`](../../rtti/data/module.f.mjs) deliberately -keeps their canonical `Node`s distinct, so `arraySet` must renormalize and -`cmp`/`equal`/`subset`, the data reader, and the printer must follow; it -makes every **interior** absent bit unobservable (absence at a position is -realizable only when every later position also admits it, so -`[or(option, number), 3]` comes to denote `[number, 3]` while `toData` keeps -the `absentBit`), so those bits must be stripped, the trailing-run split -`TupleTs` already makes; and even that strip has no local answer for a -**referenced** node — the data form declines to see through a reference -(`trimPrefix` leaves referenced positions alone), and a rule like -`r = or(option, [r])` may sit at one position where its root absence is -unobservable and another where it is not, so stripping the rule itself -changes the fixpoint, and the design would need contextual specialization or -a stated canonicality exception. All of that as a prerequisite for a -migration that does not need it; anyone wanting the past-the-end rule on its -own merits files it as an rtti issue. - -## Proposal - -Drop the `null` member of all three lambda unions and the terminals' third -operand, and let the continuation positions of `dot`, `optionDot`, -`optionCall` and the steps end by the operand's absence — spelled as -arity-split unions, per the chosen mechanism above: each node or step whose -continuation may end becomes a union of its two closed arities, with no -`option` anywhere in the chain schemas. Code changes are small; the bulk is -mechanical respelling of proofs and prose. - -[amnesia](../amnesia/module.f.mjs) barely changes: its four `k === null` -checks become `k === undefined`, since reading the continuation position of a -shorter tuple yields `undefined` — which the schema guarantees is not a -present value there. - -### Tasks - -The schemas must not mix the spellings: an arity-split arm whose lambda root -carries `option` admits the absent member — and its hole — again. - -- [ ] `../module.f.mjs`: every lambda union and continuation-carrying node - splits by arity — no `option` member in any of them, terminals are the - shorter arm, phantom annotations stay plain -- [ ] `../types.ts`: plain unions of exact tuples, one per arity, no - optional elements -- [ ] `../amnesia/module.f.mjs`: `k === null` → `k === undefined`; - signatures take `… | undefined`; keep the walkers' destructuring reads — - the iterator stops at `length`, so a prototype-supplied index past a - short node's end is never read (see the gate boundary above) — never - switch a continuation read to direct indexing, and rewrite `skip`'s - direct `k[0]`/`k[1]`/`k[2]` to destructure like the other three walkers, - since a short step's `k[2]` would otherwise read the prototype -- [ ] `../proof.f.mjs`, `../amnesia/proof.f.mjs`: respell (~200 trailing - `null`s); add rejections for present `null`, present `undefined`, the - smuggled continuation on a terminal, and the trailing holes - `['.', a, 'b', ,]` and `['|()', c, ,]`; the `unspellable` family list - holds -- [ ] `../README.md`: node and spelling tables; "Terminals state their - `null`" inverts into "closedness by length rejects a smuggled - continuation"; "The cost" shrinks -- [ ] downstream designs and other repo-wide chain spellings: - [`../../djs/todo/compile-modules-to-edag.md`](../../djs/todo/compile-modules-to-edag.md) - and [`../../djs/todo/interpret-edag.md`](../../djs/todo/interpret-edag.md) - both prescribe `['.', object, property, null]` and `['|()', args, null]` - for stages not yet implemented, which would produce or expect invalid - EDAG after the migration; respell them, - [`../../../todo/blocked/bun-optional-chain-parentheses.md`](../../../todo/blocked/bun-optional-chain-parentheses.md), - and whatever else a sweep for chain spellings finds — released - `changelog/` entries stay as written, history rather than prescription -- [ ] `changelog/unreleased/.md`, prefixed `**BREAKING CHANGES:**`, with - the matching `Changelog:` section in the PR description ([`AGENTS.md`](../../../AGENTS.md) - §5): previously valid graphs and the exported types stop accepting the - `null`-terminated shape, and every importer is updated in the same PR - -## Related - -- [`../README.md`](../README.md) — Chains; "Terminals state their `null`"; - The cost -- [`option`](../../rtti/module.f.mjs), - [`AbsentOr`/`CheckRaw`/`TupleTs`](../../rtti/ts/types.ts) — the machinery, - proven in [`../../rtti/ts/proof.f.mjs`](../../rtti/ts/proof.f.mjs) -- 7852819 / 930fa65 (#1725) — closed containers by default, then `option` as - omission; this issue is that plan's second half applied to edag -- [`../../rtti/todo/identity-aware-parse.md`](../../rtti/todo/identity-aware-parse.md) - — the identity caveats `validate(exp)` keeps either way; hole rejection - itself is structural under arity-split, pinned in the proofs, and joins - nothing diff --git a/fjs/edag/types.ts b/fjs/edag/types.ts index 679e8072c..cfc4a1713 100644 --- a/fjs/edag/types.ts +++ b/fjs/edag/types.ts @@ -73,16 +73,22 @@ export type Index = number | NumberCast | string // of hidden control flow it carries: a live receiver (`Property`) and an open // short-circuit region (`Option`). Neither bit live is a node boundary, which // is why the fourth combination is an `Exp` and not a fourth type. +// +// A chain ends by **arity**: every production that can hand the chain on is +// written twice, once carrying its continuation and once one element shorter, +// so ending is the absence of that operand rather than a `null` in it. The +// tuples stay exact, which is what keeps a trailing hole unspellable. /** * The continuation of a `Dot`: a receiver is live, no region is open. * * Only a call can be here — a property step would waste the receiver with no - * region to keep it in, so `a.b.c` nests `Dot`s instead. + * region to keep it in, so `a.b.c` nests `Dot`s instead. `|()` is terminal + * and so has only the shorter arity; `|?.()` opens a region and has both. */ export type PropertyLambda = - | null - | readonly['|()', Exp, null] + | readonly['|()', Exp] + | readonly['|?.()', Exp] | readonly['|?.()', Exp, OptionLambda] /** @@ -90,22 +96,26 @@ export type PropertyLambda = * region: `OptionCall`'s, and every call step that stays in its region. */ export type OptionLambda = - | null + | readonly['|()', Exp] | readonly['|()', Exp, OptionLambda] + | readonly['|.', Index] | readonly['|.', Index, OptionPropertyLambda] /** * The continuation of a property step inside an open region: both bits live, * so this is the state with every production — the three ways a call can * relate to the region it sits in, plus the property step the region keeps - * from leaving. + * from leaving. `|!()` closes the region and is terminal, so it alone has a + * single arity. */ export type OptionPropertyLambda = - | null + | readonly['|()', Exp] | readonly['|()', Exp, OptionLambda] + | readonly['|.', Index] | readonly['|.', Index, OptionPropertyLambda] + | readonly['|?.()', Exp] | readonly['|?.()', Exp, OptionLambda] - | readonly['|!()', Exp, null] + | readonly['|!()', Exp] // call @@ -113,15 +123,21 @@ export type Call = readonly['()', Exp, Exp] // dot -export type Dot = readonly['.', Exp, Index, PropertyLambda] +export type Dot = + | readonly['.', Exp, Index] + | readonly['.', Exp, Index, PropertyLambda] // optionDot -export type OptionDot = readonly['?.', Exp, Index, OptionPropertyLambda] +export type OptionDot = + | readonly['?.', Exp, Index] + | readonly['?.', Exp, Index, OptionPropertyLambda] // optionCall -export type OptionCall = readonly['?.()', Exp, Exp, OptionLambda] +export type OptionCall = + | readonly['?.()', Exp, Exp] + | readonly['?.()', Exp, Exp, OptionLambda] // Comma diff --git a/todo/blocked/bun-optional-chain-parentheses.md b/todo/blocked/bun-optional-chain-parentheses.md index 6258f702a..60705d0ff 100644 --- a/todo/blocked/bun-optional-chain-parentheses.md +++ b/todo/blocked/bun-optional-chain-parentheses.md @@ -48,7 +48,7 @@ runner. `chainsJs.throw` in [`fjs/edag/proof.f.mjs`](../../fjs/edag/proof.f.mjs) carries two commented-out cases for it, and the tagged-template one must stay commented rather than merely fail, because a parse error takes the whole file down. Nothing about the specification is in doubt, and the EDAG is unaffected: -`['?.', u, 'b', ['|!()', c, null]]` denotes the throwing reading, and +`['?.', u, 'b', ['|!()', c]]` denotes the throwing reading, and `optionRegion.throw.closeStepOnUndefined` in [`fjs/edag/amnesia/proof.f.mjs`](../../fjs/edag/amnesia/proof.f.mjs) pins it by evaluating the node, which is an oracle that works on every runner. From cbca4511bf86c08877c0cab0d3f6e48dbd2e8c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:13:15 +0000 Subject: [PATCH 213/370] ci: the job schema can express ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job that consumes an artifact must not start before the job that uploads it. The generator could not say so: jobSchema named only runs-on and steps, and it is deliberately closed, so a `needs:` key emitted past it would fail the round-trip that parseGitHubAction performs in fjs/ci/proof.f.mjs. Without this, the packed-artifact check in fjs/ci/todo/f-mjs-package-support.md could only fail at download-artifact — a required check red for a reason unrelated to what it tests, which is the one failure mode that trains people to re-run instead of read. Adds `needs: or(option, array(string))`, which widens Job through Ts. The generated ci.yml is unchanged: nothing orders itself yet, so the field stays absent. The proof covers what the field is for rather than that it exists: an ordered pair round-trips with its dependency intact; independent jobs still parse with it absent; a bare scalar `needs: pack` is rejected, since GitHub accepts that spelling but this generator emits the list form only, so a scalar is drift; and no generated job carries the field today, so the first one to do so is a deliberate change. Prerequisite for both fjs/ci/todo/ci-integration-tests.md's two-stage split and the Stage 2 packed-artifact check, which is why it is owned there rather than by either consumer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/common/module.f.mjs | 6 ++++++ fjs/ci/proof.f.mjs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/fjs/ci/common/module.f.mjs b/fjs/ci/common/module.f.mjs index 1c55977de..8a8be37b5 100644 --- a/fjs/ci/common/module.f.mjs +++ b/fjs/ci/common/module.f.mjs @@ -30,8 +30,14 @@ export const stepSchema = /** @type {const} */ ({ with: or(option, record(string)) }) +// `needs` is how one job waits for another: a job that consumes an artifact +// cannot start before the job that uploads it. It is optional because most jobs +// are independent, and it is named here rather than emitted past the schema — +// `parseGitHubAction` reads back the workflow this repository generates, so an +// unmodelled key would fail that round-trip in `fjs/ci/proof.f.mjs`. export const jobSchema = /** @type {const} */ ({ 'runs-on': string, + needs: or(option, array(string)), steps: array(stepSchema) }) diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index e63836aa3..7b77e74b0 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -1,6 +1,7 @@ /** * @import { MetaStep, Os, GitHubAction } from './common/types.ts' * @import { Dir, State } from '../effects/node/virtual/types.ts' + * @import { Unknown } from '../djs/types.ts' */ import { exitCode } from '../effects/node/module.f.mjs' @@ -221,4 +222,39 @@ export const proof = { assert(job['runs-on'] !== undefined, 'expected runs-on') assert(job.steps.length > 0, 'expected steps') }, + jobNeeds: () => { + const steps = [{ run: 'echo hi' }] + /** @type {(jobs: Unknown) => Unknown} */ + const action = jobs => ({ + name: 'test', + on: {}, + permissions: { contents: 'read' }, + jobs, + }) + // Modelled, so a job that waits for another survives the round-trip. + // Without this a consuming job could only reach the workflow by being + // emitted past the schema, which `parseGitHubAction` would then reject. + const ordered = unwrap(parseGitHubAction(action({ + pack: { 'runs-on': 'ubuntu-latest', steps }, + check: { 'runs-on': 'ubuntu-latest', needs: ['pack'], steps }, + }))) + assertEq(ordered.jobs.check?.needs?.[0], 'pack') + assertEq(ordered.jobs.check?.needs?.length, 1) + // Optional: the independent jobs, which is all of them today, still parse. + assertEq(unwrap(parseGitHubAction(action({ + pack: { 'runs-on': 'ubuntu-latest', steps }, + }))).jobs.pack?.needs, undefined) + // Constrained, not merely accepted. GitHub also allows a bare scalar + // (`needs: pack`); this generator emits the list form only, so the + // scalar is drift rather than an alternative spelling — the same reason + // these schemas are closed. + assertEq(parseGitHubAction(action({ + check: { 'runs-on': 'ubuntu-latest', needs: 'pack', steps }, + }))[0], 'error') + // Dormant until something orders itself: the first consumer is the + // packed-artifact check in `fjs/ci/todo/f-mjs-package-support.md`. + assert( + definedValues(run(false).jobs).every(job => job.needs === undefined), + 'unexpected job ordering in the generated workflow') + }, } From a1c4cd819918ff265fc9d131ae996b75b8121ae2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:15:04 +0000 Subject: [PATCH 214/370] emergent_testing: join a walk's outcomes once, not pairwise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folding sibling outcomes with a concatenation copied every record accumulated so far on each step, so a flat module of N leaves cost N² copies. `joinOutcomes` walks the list once instead. The changelog now says what `runModuleMap` answering an outcome rather than an exit code breaks, and its documentation no longer promises the exit code `exitCodeOf` derives. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/1759.md | 7 +++--- fjs/emergent_testing/module.f.mjs | 38 +++++++++++++++---------------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/changelog/unreleased/1759.md b/changelog/unreleased/1759.md index 9fe6c30b7..34e289e23 100644 --- a/changelog/unreleased/1759.md +++ b/changelog/unreleased/1759.md @@ -1,3 +1,4 @@ -- `emergent_testing`: the browser page runs the same proof traversal as `fjs t` - instead of its own copy, through a new browser effect interpreter. Its - per-proof batching is gone, so both runners now schedule identically +- **BREAKING CHANGES:** `emergent_testing`: `runModuleMap` answers the run's + outcome — totals and leaf records — instead of an exit code, which + `exitCodeOf` now derives. The browser page runs the same traversal as + `fjs t`, through a new browser effect interpreter diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index 6a2139964..33022a8c6 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -183,22 +183,19 @@ const mergeTotals = (a, b) => ({ passed: a.passed + b.passed, failed: a.failed + b.failed, duration: a.duration + b.duration }) /** - * The empty {@link RunOutcome}. + * Joins a list of outcomes, keeping the leaf records in the order the walk + * produced them — which is what makes a host's report ordered by structure + * rather than by which leaf settled first. * - * @type {RunOutcome} - */ -const zeroOutcome = { totals: zeroTotals, results: [] } - -/** - * Joins two outcomes, keeping the leaf records in the order the walk produced - * them — which is what makes a host's report ordered by structure rather than - * by which leaf settled first. + * The whole list is joined at once rather than pairwise: folding with a + * concatenation copies every record accumulated so far on each step, so a flat + * module of N leaves would cost N² copies. `flatMap` walks the records once. * - * @type {(a: RunOutcome, b: RunOutcome) => RunOutcome} + * @type {(a: readonly RunOutcome[]) => RunOutcome} */ -const mergeOutcome = (a, b) => ({ - totals: mergeTotals(a.totals, b.totals), - results: [...a.results, ...b.results], +const joinOutcomes = a => ({ + totals: a.reduce((t, o) => mergeTotals(t, o.totals), zeroTotals), + results: a.flatMap(o => o.results), }) /** @@ -271,14 +268,14 @@ export const runEntries = ({ result, test }) => (k, entries) => { // children its return value produced. return mapStep( walkEntries(children), - sub => mergeOutcome(self, sub)) + sub => joinOutcomes([self, sub])) }) } /** @type {(entries: readonly _TestAndPath[]) => Effect, IoChannel>} */ const walkEntries = entries => // `allOk` answers in argument order however the effects interleave, so // siblings stay in declaration order even though they run concurrently. - mapStep(allOk(...entries.map(one)), states => states.reduce(mergeOutcome, zeroOutcome)) + mapStep(allOk(...entries.map(one)), joinOutcomes) return walkEntries(entries) } @@ -310,9 +307,12 @@ const proofEntries = moduleMap => .flatMap(([k, v]) => v.proof !== undefined ? [/** @type {const} */ ([k, v.proof])] : []) /** - * Runs all test modules in `moduleMap` whose names pass `isTest`, accumulates - * pass/fail/time via `reporter`, and returns an exit code (0 = all passed, - * 1 = at least one failure). + * Runs all test modules in `moduleMap` whose names pass `isTest`, reporting + * each leaf through `reporter` and its totals through `reporter.summary`. + * + * The answer is the run's {@link RunOutcome}: the folded totals, and every + * leaf record the reporter answered with, in structural order. A caller that + * wants the run's exit code asks {@link exitCodeOf} for it. * * @template {Operation} O * @template R @@ -324,7 +324,7 @@ export const runModuleMap = reporter => moduleMap => { const modules = proofEntries(moduleMap) const total = mapStep( allOk(...modules.map(([k, v]) => runModule(reporter)(k, v))), - m => m.reduce(mergeOutcome, zeroOutcome)) + joinOutcomes) // The outcome is still needed after the summary has been printed, so it is // carried forward in a history rather than closed over by a nested // continuation. From cdbf4d5292499b124eedc1c8a6caa7fb398ab80c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:31:43 +0000 Subject: [PATCH 215/370] changelog: name the entry by its real PR number Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- changelog/unreleased/{1759.md => 1761.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/unreleased/{1759.md => 1761.md} (100%) diff --git a/changelog/unreleased/1759.md b/changelog/unreleased/1761.md similarity index 100% rename from changelog/unreleased/1759.md rename to changelog/unreleased/1761.md From aee1747003832b8d2303ae29e718f8aeca4111d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:33:41 +0000 Subject: [PATCH 216/370] todo: the object-node shorthand is not an empty object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a trap in this issue's own instructions. "Every `["{}"]` → `["{}", []]`" reads as a blanket rewrite, but one of the seven occurrences in `edag-stage1-discussion.md` is not an empty object: the validation rule writes "every element of a `["{}"]` node's entry array must be a recognized entry form", where the tag names *an object-constructor node*. Rewriting that one with the examples makes the sentence contradict itself — an empty entry array has no elements to check — so an implementer following this issue literally would have introduced the defect. It is also the occurrence #1756 introduced rather than inherited: the previous wording said "every `["{}", ...]` operand", which stopped describing the shape once the operands became one array. Added as a fourth class with its own treatment. The shorthand only became ambiguous when `["{}", []]` acquired a meaning, so it wants a nonempty placeholder — `["{}", entries]` — rather than the empty form or a bare tag. The counts in the problem statement, the class-1 blurb and the task list are qualified to match: six genuine empty objects, one shorthand. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- todo/edag-stage1-flat-examples.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/todo/edag-stage1-flat-examples.md b/todo/edag-stage1-flat-examples.md index 0a076aeef..bc95ac1da 100644 --- a/todo/edag-stage1-flat-examples.md +++ b/todo/edag-stage1-flat-examples.md @@ -11,8 +11,9 @@ structural constructors in the nested form its normative parts now use — [`fjs/edag/module.f.mjs`](../fjs/edag/module.f.mjs) and the form column in [`fjs/edag/README.md`](../fjs/edag/README.md) — but roughly a dozen of its worked examples still spell the operands flat, as `["[]", x, x]`. The empty -object appears throughout as the one-element `["{}"]`, which the schema -writes `["{}", []]`. +object appears in six of them as the one-element `["{}"]`, which the schema +writes `["{}", []]` — but a seventh `["{}"]` is not an empty object at all +(class 4 below), so that rewrite is not a blanket one. The normative places were corrected in [#1756](https://github.com/functionalscript/functionalscript/pull/1756) after @@ -31,10 +32,11 @@ than the P2 the original finding carried. ### Proposal -Three classes, and only the first is mechanical. +Four classes, and only the first is mechanical. **1. Re-notate — examples describing today's shape.** Rewrite these to the -nested form, including `["{}"]` → `["{}", []]`: +nested form; the `["{}"]` occurrences here are genuine empty objects and +become `["{}", []]`: |Section|What it shows| |-|-| @@ -70,12 +72,23 @@ spelling is what the quoted proposal said: constructor*, and *6. Command vocabulary* say nothing about operand grouping and are not claims about shape. +**4. Re-spell, don't re-notate — the generic node shorthand.** *Validation* +writes "every element of a `["{}"]` node's entry array must be a recognized +entry form". That `["{}"]` names *an object-constructor node*, not an empty +one, so rewriting it to `["{}", []]` with the class-1 examples contradicts the +sentence: an empty entry array has no elements to check. It is also the one +occurrence #1756 introduced rather than inherited. The shorthand only became +ambiguous once `["{}", []]` acquired a meaning, so give it a nonempty +placeholder — `["{}", entries]` — rather than the empty form or a bare tag. + Sections are named rather than line numbers cited, per [tokenizer-line-citations](../fjs/js/todo/tokenizer-line-citations.md). ### Tasks -- [ ] Re-notate the class-1 examples, including every `["{}"]` → `["{}", []]`. +- [ ] Re-notate the class-1 examples, including the six `["{}"]` that really + are empty objects → `["{}", []]`. Not the seventh: see class 4. +- [ ] Re-spell the class-4 shorthand as `["{}", entries]`. - [ ] Decide each of the three class-2 passages: leave, annotate, or rewrite. - [ ] Re-read the document for flat forms this inventory missed — it was built by grepping `["[]"` and `["{}"`, which does not catch a constructor From 7cd8db905a2ea1f8e26f3fc0600604371acc2e78 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:39:10 +0000 Subject: [PATCH 217/370] emergent_testing: follow the example for the browser catch, and reconcile the todo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser interpreter's `catch` spelled out a `try`/`catch` that `types/result/module.mjs` already exports as `tryCatch` — the helper `effects/node` uses for the same operation, and one that carries no host dependency, so there was nothing for a browser to do differently. The todo's Tasks list still read as open where the merged steps had settled it: the shared API, the named parts, `collectTests` as the single source of truth, the `effects/browser/` decision, the cross-runner equivalence proofs, and the two behaviours the shared core does not keep. The one branch of the page still without a proof — the run's own dispatch failure — is a task now, with what makes it hard to reach. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/1759.md | 6 +-- fjs/effects/browser/module.mjs | 14 +++--- .../todo/share-browser-console-runner.md | 45 ++++++++++++++----- 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/changelog/unreleased/1759.md b/changelog/unreleased/1759.md index 34e289e23..725035191 100644 --- a/changelog/unreleased/1759.md +++ b/changelog/unreleased/1759.md @@ -1,4 +1,4 @@ - **BREAKING CHANGES:** `emergent_testing`: `runModuleMap` answers the run's - outcome — totals and leaf records — instead of an exit code, which - `exitCodeOf` now derives. The browser page runs the same traversal as - `fjs t`, through a new browser effect interpreter + outcome — totals and leaf records — not an exit code, which `exitCodeOf` + derives. The browser page runs the same traversal as `fjs t`, through a new + browser interpreter diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index c1dfe4cf0..4b70c67bd 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -30,6 +30,7 @@ import { asyncRun } from '../module.mjs' import { error, ok } from '../../types/result/module.f.mjs' +import { tryCatch } from '../../types/result/module.mjs' /** * Calls `f` and answers what happened — its value, or the value it threw — @@ -91,14 +92,11 @@ export const browserRun = extra => { sandbox: async (/** @type {() => unknown} */ f) => ok(await sandbox(f)), // No clock and no fixture convention — see `Catch` in // `../node/types.ts` for why this is a second operation beside - // `sandbox` rather than a use of it. - catch: async (/** @type {() => unknown} */ f) => { - try { - return ok(ok(f())) - } catch (e) { - return ok(error(e)) - } - }, + // `sandbox` rather than a use of it. It is `tryCatch`, spelled the + // same way `effects/node` spells it: that helper carries no host + // dependency, so there is nothing here for a browser to do + // differently. + catch: async (/** @type {() => unknown} */ f) => ok(tryCatch(f)), ...extra, })) return run diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index e0c921001..a7a143110 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -465,13 +465,19 @@ are shared. ### Tasks -- [ ] Inventory duplicated semantics in `emergent_testing/module.f.mjs` and - `emergent_testing/browser.mjs`, and define the smallest shared API. -- [ ] Name the skeleton's parts explicitly — execute a leaf, report a result, +- [x] Inventory duplicated semantics in `emergent_testing/module.f.mjs` and + `emergent_testing/browser.mjs`, and define the smallest shared API. The + shared API is `Reporter` and the `RunOutcome` the traversal + answers with; the page supplies the parts and nothing else. +- [x] Name the skeleton's parts explicitly — execute a leaf, report a result, link a module — and check that nothing host-specific is left outside one - of them. -- [ ] Make the existing `collectTests`/path behavior the single source of truth - for console and browser execution. + of them. `test`, `result` and `summary` are the parts; linking a module + stays outside the skeleton, which is why `runEntries` exists beside + `runModuleMap`. +- [x] Make the existing `collectTests`/path behavior the single source of truth + for console and browser execution. The page's own walk is deleted; it + calls `collectTests` once, under its own guard, and hands the leaves to + `runEntries`. - [x] Share the test-name format, and prove both runners name the same leaf identically. The browser report carries a `name` built by `fmtImport`, and `nameMatchesTheConsoleRunner` pins it to that function rather than to a @@ -481,8 +487,12 @@ are shared. `TestResult`, built by `testResult`, carrying identity, status and duration. Progress, infrastructure-error, totals and report values are still each host's own. -- [ ] Decide whether browser import/time/yield/publication justify +- [x] Decide whether browser import/time/yield/publication justify `fjs/effects/browser/`; document the decision before adding operations. + They do not: the interpreter implements `sandbox`, `catch` and `all` and + nothing else — import, time and publication are the page's, in its + impure shell. Recorded in that module and in + `effects/todo/node-module-layering.md`. - [ ] Move static proof discovery and `_browser-suite.mjs` generation into `fjs/website/module.f.mjs`; extend `fjs/effects/node/` only for a concrete missing capability and prove the real and virtual interpretations. @@ -497,12 +507,25 @@ are shared. interpretation, DOM rendering, and browser publication. - [ ] Update the generated website entry and browser-test application imports to the new module paths. -- [ ] Prove both runners produce equivalent paths, throw outcomes, recursive - test counts, and normalized failures from the same fixtures. -- [ ] Record every behaviour the browser file has today and the shared core will - not keep, as an issue, before the sharing change merges. +- [x] Prove both runners produce equivalent paths, throw outcomes, recursive + test counts, and normalized failures from the same fixtures. They now + share the code that decides all four, and `nameMatchesTheConsoleRunner`, + `expectedThrowStatusMatchesTheSharedOne` and + `normalizedResultMatchesTheSharedOne` assert against the console + runner's own functions rather than against a spelling. +- [x] Record every behaviour the browser file has today and the shared core will + not keep, as an issue, before the sharing change merges. Two: the + `batchSize = 25` yielding, deleted deliberately so both runners schedule + identically, and the unguarded read of a module's *exported* tree, which + stays the page's own and is tracked by + [hostile-proof-values](./hostile-proof-values.md). - [ ] Close each of those issues for both runners at once, so the two stay in sync rather than drifting from the day the core is shared. +- [ ] Prove `runBrowserProofs`'s `infrastructure-error` branch — the run's own + dispatch failure, as opposed to any proof's. It is the one branch of the + page with no proof, and reaching it needs an effect the browser + interpreter does not implement, which the public entry point gives no way + to inject. ### Related From f9cab0e80e76ff7d6bb925505570628a323041ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:39:21 +0000 Subject: [PATCH 218/370] ci: pin the proof's steps literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fjs/AGENTS.md requires a const with a literal initializer to pin its type; the fixture array relied on tsc's default widening, which drops readonly and the literal types — exactly what a schema-oriented proof depends on. The other two consts added here are exempt by the same rule: an arrow function and a call are not literal initializers and already carry non-widening types. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/proof.f.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index 7b77e74b0..b0c869039 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -223,7 +223,7 @@ export const proof = { assert(job.steps.length > 0, 'expected steps') }, jobNeeds: () => { - const steps = [{ run: 'echo hi' }] + const steps = /** @type {const} */ ([{ run: 'echo hi' }]) /** @type {(jobs: Unknown) => Unknown} */ const action = jobs => ({ name: 'test', From 961e885bca5e25a0943a2671571adb9443a9ee26 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:47:17 +0000 Subject: [PATCH 219/370] emergent_testing: join a walk's records as a rope, not an array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Joining siblings once fixed the fan-out, but a parent still rebuilt an array holding every record its children had produced, so a chain of depth N cost N² copies — the same defect one level down. The walk now accumulates a `_RunAcc`: the totals, and the records as a `List`, where a join is one node. The rope is walked out to an array exactly once, where a run ends, so no level pays for the levels below it. `runModuleMap` joins modules that have each already flattened, which copies each record once and nothing more. Each record is boxed, because a `List` reads a bare array or function in an element position as a sub-list to splice. `R` is the host's own leaf record and this module has no business restricting what it may be. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/emergent_testing/browser/proof.mjs | 10 ++++++ fjs/emergent_testing/module.f.mjs | 48 +++++++++++++++++--------- fjs/emergent_testing/types.ts | 22 ++++++++++++ 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 8afc48c0f..5938751e6 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -354,6 +354,16 @@ export const proof = { assertStructurallySame([...p.states], ['running', 'failed']) assertEq(p.view.events.length, 1) }, + // A parent precedes the children its return value produced, however deep + // the chain goes — the records are joined as a rope and walked out once, + // so nesting must not reorder them the way a per-level rebuild could. + deepChainKeepsStructuralOrder: async () => { + const report = await run({ a: () => ({ b: () => ({ c: () => ({ d: () => undefined }) }) }) }) + assertEq(report.totals.tests, 4) + assertStructurallySame( + report.results.map(r => r.path), + ['.a', '.a().b', '.a().b().c', '.a().b().c().d']) + }, exportedTreeIsReadOnce: async () => { // The export is enumerated exactly once. A getter that succeeds on the // first read and throws on the next is not a module failure here — but diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index 33022a8c6..1ce4fd7d2 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -14,7 +14,7 @@ * @import { Result } from '../types/result/types.ts' * @import { Effect, NotImplemented } from '../effects/types.ts' * @import { LoadModuleOperations, ModuleMap } from '../dev/types.ts' - * @import { TestFn, TestEntry, TestSet, Path, Reporter, RunOutcome, RunTotals, TestResult, _TestAndPath } from './types.ts' + * @import { TestFn, TestEntry, TestSet, Path, Reporter, RunOutcome, RunTotals, TestResult, _RunAcc, _TestAndPath } from './types.ts' * @import { All, Await, Catch, Env, IoChannel, NodeProgram, NodeProgramOptions, Program, Sandbox, SandboxResult, Test, TestContext, Write, WriteConsoles } from '../effects/node/types.ts' */ @@ -25,6 +25,7 @@ import { } from '../effects/module.f.mjs' import { loadModuleMap } from '../dev/module.f.mjs' import { invert } from '../types/result/module.f.mjs' +import { concat, flat, toArray } from '../types/list/module.f.mjs' import { definedEntries } from '../types/object/module.f.mjs' /** @@ -183,21 +184,31 @@ const mergeTotals = (a, b) => ({ passed: a.passed + b.passed, failed: a.failed + b.failed, duration: a.duration + b.duration }) /** - * Joins a list of outcomes, keeping the leaf records in the order the walk + * Joins what a walk accumulated, keeping the leaf records in the order it * produced them — which is what makes a host's report ordered by structure * rather than by which leaf settled first. * - * The whole list is joined at once rather than pairwise: folding with a - * concatenation copies every record accumulated so far on each step, so a flat - * module of N leaves would cost N² copies. `flatMap` walks the records once. + * Nothing is copied here. Both places a walk joins — siblings fanned out, and a + * parent in front of the children its return value produced — hand the records + * on as `List` nodes, so a wide module and a deep one both cost one node per + * join instead of a copy of everything joined so far. See {@link _RunAcc}. * - * @type {(a: readonly RunOutcome[]) => RunOutcome} + * @type {(a: readonly _RunAcc[]) => _RunAcc} */ -const joinOutcomes = a => ({ +const joinAcc = a => ({ totals: a.reduce((t, o) => mergeTotals(t, o.totals), zeroTotals), - results: a.flatMap(o => o.results), + results: flat(a.map(o => o.results)), }) +/** + * The array of leaf records a host is answered with, walked out of the rope + * once. Done where a run ends rather than inside the walk, so no level pays + * for the levels below it. + * + * @type {(a: _RunAcc) => RunOutcome} + */ +const outcomeOf = a => ({ totals: a.totals, results: toArray(a.results).map(b => b.value) }) + /** * Runs already-collected leaves under the module name `k`. * @@ -213,7 +224,7 @@ const joinOutcomes = a => ({ * @returns {(k: string, entries: readonly _TestAndPath[]) => Effect, IoChannel>} */ export const runEntries = ({ result, test }) => (k, entries) => { - /** @type {(entry: _TestAndPath) => Effect, IoChannel>} */ + /** @type {(entry: _TestAndPath) => Effect, IoChannel>} */ const one = ([testPath, set]) => { // The leaf's shared record is built here, next to the sandbox result it // is read from, so the leaf-landed event carries the value already @@ -259,8 +270,8 @@ export const runEntries = ({ result, test }) => (k, entries) => { return step( reported, ([r, [t, , children]]) => { - /** @type {RunOutcome} */ - const self = { totals: addResult(zeroTotals, t), results: [r] } + /** @type {_RunAcc} */ + const self = { totals: addResult(zeroTotals, t), results: [{ value: r }] } if (children.length === 0) { return pureOk(self) } @@ -268,15 +279,15 @@ export const runEntries = ({ result, test }) => (k, entries) => { // children its return value produced. return mapStep( walkEntries(children), - sub => joinOutcomes([self, sub])) + sub => joinAcc([self, sub])) }) } - /** @type {(entries: readonly _TestAndPath[]) => Effect, IoChannel>} */ + /** @type {(entries: readonly _TestAndPath[]) => Effect, IoChannel>} */ const walkEntries = entries => // `allOk` answers in argument order however the effects interleave, so // siblings stay in declaration order even though they run concurrently. - mapStep(allOk(...entries.map(one)), joinOutcomes) - return walkEntries(entries) + mapStep(allOk(...entries.map(one)), joinAcc) + return mapStep(walkEntries(entries), outcomeOf) } /** @@ -322,9 +333,14 @@ const proofEntries = moduleMap => export const runModuleMap = reporter => moduleMap => { const { summary } = reporter const modules = proofEntries(moduleMap) + // Each module has already walked out its own records, so joining the + // modules copies each record once and nothing more. const total = mapStep( allOk(...modules.map(([k, v]) => runModule(reporter)(k, v))), - joinOutcomes) + m => ({ + totals: m.reduce((t, o) => mergeTotals(t, o.totals), zeroTotals), + results: m.flatMap(o => o.results), + })) // The outcome is still needed after the summary has been printed, so it is // carried forward in a history rather than closed over by a nested // continuation. diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index 90258f75e..cbbcc6947 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -3,6 +3,7 @@ */ import type { Effect, Operation } from '../effects/types.ts' +import type { List } from '../types/list/types.ts' import type { IoChannel, OpResult, SandboxResult } from '../effects/node/types.ts' /** A zero-argument test function whose return value may contain sub-tests. */ @@ -231,5 +232,26 @@ export type RunOutcome = { readonly results: readonly R[] } +/** + * What the walk itself accumulates, before the run answers a {@link RunOutcome}. + * + * The records are a `List` rather than an array because joining two arrays + * copies both: a parent that joined its children's records would recopy every + * descendant at every level, and the walk would cost more the deeper it went. + * `concat` on a `List` is one node, and `toArray` walks the whole rope once, + * at the end. + * + * Each record is **boxed**, because a `List` reads a bare array or function in + * an element position as a sub-list to splice. `R` is the host's own leaf + * record and this module has no business restricting what it may be, so it + * never puts one in that position. + * + * @internal + */ +export type _RunAcc = { + readonly totals: RunTotals + readonly results: List<{ readonly value: R }> +} + /** @internal */ export type _TestAndPath = readonly [Path, TestEntry] From 1580f061f39ef7ef29f23c0df99415b34139c4d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:50:03 +0000 Subject: [PATCH 220/370] emergent_testing: drop the unused list import `joinAcc` names both lists at once through `flat`, so `concat` was imported and never called. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/emergent_testing/module.f.mjs | 2 +- fjs/emergent_testing/types.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index 1ce4fd7d2..6ef49cd6e 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -25,7 +25,7 @@ import { } from '../effects/module.f.mjs' import { loadModuleMap } from '../dev/module.f.mjs' import { invert } from '../types/result/module.f.mjs' -import { concat, flat, toArray } from '../types/list/module.f.mjs' +import { flat, toArray } from '../types/list/module.f.mjs' import { definedEntries } from '../types/object/module.f.mjs' /** diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index cbbcc6947..b96686733 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -238,8 +238,8 @@ export type RunOutcome = { * The records are a `List` rather than an array because joining two arrays * copies both: a parent that joined its children's records would recopy every * descendant at every level, and the walk would cost more the deeper it went. - * `concat` on a `List` is one node, and `toArray` walks the whole rope once, - * at the end. + * Joining `List`s is a node that names them, and `toArray` walks the whole + * rope once, at the end. * * Each record is **boxed**, because a `List` reads a bare array or function in * an element position as a sub-list to splice. `R` is the host's own leaf From 9e9ae868473511a0f052c2dfcf04c1fb802d21bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:50:41 +0000 Subject: [PATCH 221/370] todo/edag: respell the stage-1 discussion for the new arities Review finding on #1761: edag-stage1-discussion.md is an open working design the staged compiler work links to, and its operations table and worked examples still prescribed null-terminated dots and call steps, so following them now produces graphs the schema rejects. Respelled to the two arities, with the prefix argument updated to match the module's. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- todo/edag-stage1-discussion.md | 40 +++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/todo/edag-stage1-discussion.md b/todo/edag-stage1-discussion.md index cc2ef7604..5eeb20c66 100644 --- a/todo/edag-stage1-discussion.md +++ b/todo/edag-stage1-discussion.md @@ -94,14 +94,14 @@ the EDAG's sharing structure and DJS's graph structure are the same thing. ```js // const f = (...a) => { const x = a[0]; return [x, x] } -const x = [".", ["args"], 0, null] +const x = [".", ["args"], 0] export default ["[]", x, x] // the body is one node; x is interior // (...a) => { const check = a[0].length; return a[1] } — with comma (later) const a = ["args"] export default [",", - [".", [".", a, 0, null], "length", null], // assert: value unused - [".", a, 1, null], // the result: last, as in JS (a, b) → b + [".", [".", a, 0], "length"], // assert: value unused + [".", a, 1], // the result: last, as in JS (a, b) → b ] ``` @@ -220,12 +220,12 @@ schema is free to change independently of both. |`["[]", [...node]]`|`[…]`|1|array constructor; the elements are one operand, an array of nodes — not spread across the tuple| |`["{}", [...entry]]`|`{ … }`|1|ordered object constructor; the entries are one operand, an array — initial entry form is `[":", key, value]` (subject 4)| |`["args"]`|—|1|the arguments array (subject 2)| -|`[".", object, property, k]`|`o.p`, `o[p]`, `o.p(...args)`|1|property access, owning whatever its receiver is used for; `k` is `null` for a plain read; `property` is restricted (see below)| +|`[".", object, property]`, `[".", object, property, k]`|`o.p`, `o[p]`, `o.p(...args)`|1|property access, owning whatever its receiver is used for; a plain read leaves `k` out and is the shorter tuple; `property` is restricted (see below)| |`["()", callee, args]`|`f(...args)`|2|call with no receiver; `args` is one node yielding an array (subject 6)| -|`["?.", object, property, k]`|`o?.p`, and the rest of its optional region|later|optional property access; same `property` restriction| -|`["?.()", callee, args, k]`|`f?.(...args)`, and the rest of its optional region|later|optional call| -|`["\|()", args, k]`|one chain step, `(...args)`|2|not an `exp` node — only valid as the continuation `k` of a chain node or another step (subject 6); this is the step a method call's `.` node carries, so Stage 2 needs it| -|`["\|.", property, k]`, `["\|?.()", args, k]`, `["\|!()", args, null]`|one chain step|later|the remaining steps: a property access inside an optional region, a guarded call, and the call a group puts outside the region| +|`["?.", object, property]`, `["?.", object, property, k]`|`o?.p`, and the rest of its optional region|later|optional property access; same `property` restriction| +|`["?.()", callee, args]`, `["?.()", callee, args, k]`|`f?.(...args)`, and the rest of its optional region|later|optional call| +|`["\|()", args]`, `["\|()", args, k]`|one chain step, `(...args)`|2|not an `exp` node — only valid as the continuation `k` of a chain node or another step (subject 6); this is the step a method call's `.` node carries, so Stage 2 needs it| +|`["\|.", property, k?]`, `["\|?.()", args, k?]`, `["\|!()", args]`|one chain step|later|the remaining steps: a property access inside an optional region, a guarded call, and the call a group puts outside the region| |`["own", object, key]`|`Object.getOwnPropertyDescriptor(o, k)?.value`|later|own property by a computed **string**; no prototype chain| |`["Number", node]`|`Number(x)`|later|numeric coercion that accepts bigints, unlike unary `+`| |`["String", node]`|`String(x)`|later|string coercion| @@ -256,7 +256,7 @@ operator symbols below. This is [DESIGN.md §8](../DESIGN.md) again: the host language already spells these, so the EDAG reuses the spelling instead of inventing a vocabulary to be memorized and translated. A chain step is the same spelling behind a `"|"`, which marks it as a step rather than a node — -and the prefix is load-bearing, not decorative: without it `["()", f, null]` +and the prefix is load-bearing, not decorative: without it `["()", f, k]` would read equally as a call node and as a chain step. `"|."` is the `.b` of a chain, `"|?.()"` its `?.(…)`, and `"|!()"` the call a group puts outside an optional region. @@ -362,8 +362,8 @@ All operators are post-stage-1: stage 1 has no operators at all. copied into a frame when the function object is created — the scheme [function-frame](../spec/todo/3111-function-frame.md) chooses — and `["frame"]` is that array. It needs no accessor of its own: a slot is -ordinary indexing, `[".", ["frame"], 0, null]`, exactly as an argument is -`[".", ["args"], 0, null]` (subject 2). +ordinary indexing, `[".", ["frame"], 0]`, exactly as an argument is +`[".", ["args"], 0]` (subject 2). Frame construction mirrors a call: `["=>", frame, body]`, where `frame` is one node evaluating to an array — built in the *enclosing* @@ -376,7 +376,7 @@ one for creating a closure. // inside f, building b — f puts its own ["self"] into b's frame: ["=>", ["[]", ["self"]], /* b's body */ …] // inside b, calling f — slot 0 of b's frame: -["()", [".", ["frame"], 0, null], ["[]", [".", ["args"], 0, null]]] +["()", [".", ["frame"], 0], ["[]", [".", ["args"], 0]]] ``` Consequences: @@ -723,15 +723,15 @@ arguments passed to the function.** `.length` and `toString(f)` fidelity (subject 7). - The rejected `["arg", i]` (single-argument access, no reified array) cannot express rest parameters (`(...xs) => xs`) or forwarding; - `["arg", i]` is expressible as `[".", ["args"], i, null]` while the reverse + `["arg", i]` is expressible as `[".", ["args"], i]` while the reverse is not. Examples — named parameters are positions in the arguments array; the compiler erases names: ```js -const f = (...a) => a[5] // [".", ["args"], 5, null] -const g = (a) => a[5] // [".", [".", ["args"], 0, null], 5, null] +const f = (...a) => a[5] // [".", ["args"], 5] +const g = (a) => a[5] // [".", [".", ["args"], 0], 5] ``` #### 3. Lazy operators and the branch extension path @@ -908,7 +908,7 @@ the FJS compiler would never emit. To validate: unrepresentable ([Operations](#operations)). `"Number"` never returns a string, so it can never rebuild a prohibited name at run time — unlike `"+"`, which concatenates at its binary arity - (`[".", o, ["+", "constr", "uctor"], null]` would reach `Object`) and does + (`[".", o, ["+", "constr", "uctor"]]` would reach `Object`) and does not exist at all at unary arity (above). The prohibited-name list comes from [property-accessor](../spec/todo/2330-property-accessor.md), and @@ -969,7 +969,7 @@ independently (`(a?.b).c` throws where `a?.b.c` is `undefined`). The settled vocabulary keeps every `exp` evaluating to an ordinary value and carries both kinds of control flow in a **continuation** operand: a linked chain of steps that only `"."`, `"?."`, and `"?.()"` interpret. `a.b(...c)` -is then `[".", a, "b", ["|()", c, null]]`, and the vocabulary also spells +is then `[".", a, "b", ["|()", c]]`, and the vocabulary also spells chains no property-plus-call tag could, such as `(a?.b.c)(...d)`. An intermediate revision made that operand a flat *array* of steps held by @@ -991,8 +991,8 @@ an optimization: |EDAG|2330|key| |---|----|---| -|`[".", o, p, null]`|`at`, plus `instance_property` for the implemented names 2330 lists — 2330 routes every other name to `own_property`|string constant (permitted), or a number| -|`[".", o, p, ["\|()", args, null]]`|`instance_method_call` + `at_call`|same| +|`[".", o, p]`|`at`, plus `instance_property` for the implemented names 2330 lists — 2330 routes every other name to `own_property`|string constant (permitted), or a number| +|`[".", o, p, ["\|()", args]]`|`instance_method_call` + `at_call`|same| |`["own", o, k]`|`own_property`|any computed string; own properties only| `"."` merges 2330's static-name and numeric-index commands because the @@ -1247,7 +1247,7 @@ name the function did not compute itself: **Largely answered by `["frame"]`** ([Operations](#operations)): free values are captured into the frame when the closure is created, and read -back as `[".", ["frame"], i, null]`. `["self"]` covers self-reference, which +back as `[".", ["frame"], i]`. `["self"]` covers self-reference, which no frame can seed at the top level. What remains open: - **which values go into a frame, and in what order** — the compiler From b103835f7152624e2320386a9e23bb175b4b6939 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:51:32 +0000 Subject: [PATCH 222/370] edag/amnesia: record why the read pattern is not a hardening claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #1761. The scoping this rests on lived in the todo this PR deletes, and AGENTS.md requires a design decision to be captured in a README or JSDoc before the issue file goes: under a hostile host every read style has its own hole — an unchecked index reads the prototype, and destructuring dispatches an own Symbol.iterator that can yield past length — neither peculiar to ending by arity, since the walkers destructured before it too. amnesia assumes a DJS value on a pristine host, which is one more reason it is not a VM; hermetic reads are rtti's tracked question. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/edag/amnesia/README.md | 20 ++++++++++++++++++++ fjs/edag/amnesia/module.f.mjs | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/fjs/edag/amnesia/README.md b/fjs/edag/amnesia/README.md index f2552b6d5..67f763e0c 100644 --- a/fjs/edag/amnesia/README.md +++ b/fjs/edag/amnesia/README.md @@ -21,6 +21,26 @@ on. ## Why it is not a VM +### It trusts its host + +Every node and step is read by **destructuring** (`const [o, e, cont] = k`), +which is what lets a chain end by arity: the array iterator stops at `length`, +so an absent continuation reads as `undefined` rather than as whatever a +prototype supplies at that index — an indexed `k[2]` would read the prototype, +which is why none appears. + +That choice is not a hardening claim, and no read style would be one. Under a +hostile host each has its own hole: an unchecked index reads through the +prototype, and destructuring dispatches an own `Symbol.iterator`, which can +yield a step past the length `validate` bounded. Neither is peculiar to +ending by arity — the walkers destructured before it too — and closing them +means a hermetic read path, which is +[rtti's tracked question](../../rtti/todo/hostile-accessor-hermetic-read-path.md) +for the readers and a VM's problem for an executor. This evaluator's +guarantees assume what the language itself can build: a DJS value on a +pristine host, where every read style coincides. That is one more reason it +is not a VM, and nothing that matters should run on it. + ### It forgets — hence the name The model memoizes every node by identity within one invocation, so a shared diff --git a/fjs/edag/amnesia/module.f.mjs b/fjs/edag/amnesia/module.f.mjs index 38a8eb1fd..091453147 100644 --- a/fjs/edag/amnesia/module.f.mjs +++ b/fjs/edag/amnesia/module.f.mjs @@ -106,7 +106,10 @@ const callProperty = (f, obj, prop, e) => obj[prop](...argsOf(f, e)) * index: destructuring goes through the array iterator, which stops at * `length`, so a short step's absent continuation reads as `undefined` and * never as whatever a prototype supplies at that index. An indexed `k[2]` - * would, which is why none appears here. + * would, which is why none appears here. This is not a hardening claim — + * under a hostile host an own `Symbol.iterator` can yield past `length` just + * as an unchecked index reads the prototype; see "It trusts its host" in + * `./README.md`. * * @type {(f: (_: Exp) => unknown, k: OptionLambda | OptionPropertyLambda | undefined) => unknown} */ From da878ca7ad60393fb3ed3d38266cfea8746462fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:59:52 +0000 Subject: [PATCH 223/370] emergent_testing: a run that fails as a runner still reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `asyncRun` dispatches by exact match and panics on a command no handler claims, so an operation the page does not implement rejected the run's promise. `runBrowserProofs` handled only the error channel, so that rejection escaped: no report, no completion event, and a page left in `running` — the one outcome it must never reach. Both routes now end in the same `infrastructure-error` report. `browserRun` asked for a `Partial` map, which promised a recovery it does not perform. It asks for a complete one, and says where a host that wants a hole to be an ordinary outcome should build instead. `effects/browser/proof.mjs` pins the interpreter's own contract: a throw answered rather than propagated, an awaited leaf timed by what it did, argument-order answers from `all`, and the panic on an unclaimed command. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/module.mjs | 12 +++- fjs/effects/browser/proof.mjs | 62 +++++++++++++++++++ fjs/emergent_testing/browser.mjs | 33 ++++++---- .../todo/share-browser-console-runner.md | 10 +-- 4 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 fjs/effects/browser/proof.mjs diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index 4b70c67bd..93f16f62d 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -24,7 +24,7 @@ * * @module * - * @import { Operation, OperationMap } from '../types.ts' + * @import { Operation, ToAsyncOperationMap } from '../types.ts' * @import { Result } from '../../types/result/types.ts' */ @@ -82,7 +82,15 @@ const sandbox = async f => { * the children interleave, which is what lets the shared traversal report in * structural order. * - * @type {(extra: Partial>) => (effect: unknown) => Promise} + * `extra` is a **complete** map of the operations it names, not a partial one. + * `asyncRun` dispatches by exact match and panics on a command no handler + * claims, so a type that accepted holes would promise a recovery this runner + * does not perform — an omitted handler rejects the run's promise rather than + * answering `NotImplemented` through the error channel. A host that wants a + * hole to be an ordinary outcome builds its runner on `partialMatch`, the way + * `effects/mock` does. + * + * @type {(extra: ToAsyncOperationMap) => (effect: unknown) => Promise} */ export const browserRun = extra => { /** @type {(effect: any) => Promise} */ diff --git a/fjs/effects/browser/proof.mjs b/fjs/effects/browser/proof.mjs new file mode 100644 index 000000000..bb24c2e8e --- /dev/null +++ b/fjs/effects/browser/proof.mjs @@ -0,0 +1,62 @@ +/** + * Proofs for the browser interpreter. + * + * It is a `.mjs` because the interpreter is: these run its real `try`/`catch`, + * its real clock and its real `Promise.all`, which is the whole of what it is. + */ + +import { assert, assertEq } from '../../asserts/module.f.mjs' +import { browserRun } from './module.mjs' +import { all, catch_, sandbox } from '../node/module.f.mjs' +import { do_ } from '../module.f.mjs' + +/** @type {(effect: unknown) => Promise} */ +const run = browserRun(/** @type {any} */ ({})) + +export const proof = { + // A leaf that throws is an answer, not a failure of the run — the same + // bargain `effects/node`'s `sandbox` makes. + sandboxReportsAThrow: async () => { + const r = await run(sandbox(() => { throw 'boom' })) + assertEq(r[0], 'ok') + assertEq(r[1].result[0], 'error') + assertEq(r[1].result[1], 'boom') + assert(r[1].duration >= 0) + }, + // An asynchronous leaf is timed by what it did, not by how quickly it + // handed back a promise. + sandboxAwaitsAPromise: async () => { + const r = await run(sandbox(() => Promise.resolve(1))) + assertEq(r[1].result[1], 1) + }, + catchAnswersTheThrownValue: async () => { + const r = await run(catch_(() => { throw 'thrown' })) + assertEq(r[0], 'ok') + assertEq(r[1][0], 'error') + assertEq(r[1][1], 'thrown') + }, + // `all` answers in argument order however its children interleave, which is + // what lets the shared traversal report in structural order. + allAnswersInArgumentOrder: async () => { + const slow = sandbox(() => new Promise(resolve => setTimeout(() => resolve('first'), 10))) + const fast = sandbox(() => 'second') + const r = await run(all(slow, fast)) + assertEq(r[1][0][1].result[1], 'first') + assertEq(r[1][1][1].result[1], 'second') + }, + // A command no handler claims is a panic, not a `NotImplemented` answer: + // this runner dispatches by exact match, which is why `browserRun` asks for + // a complete map of the operations it is given. A host that wants a hole to + // be an ordinary outcome builds on `partialMatch` instead. + missingOperationRejects: async () => { + let thrown = false + // Side effect: `try`/`catch` is not allowed in FunctionalScript, which + // is why this proof is not one. + try { + await run(/** @type {any} */ (do_('missing'))()) + } catch { + thrown = true + } + assert(thrown) + }, +} diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 0adba2d5f..7e7e4b7ab 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -215,26 +215,37 @@ export const runBrowserProofs = (modules, result = () => undefined) => { return ok(r) }, })) + /** + * The run failed as a *runner*, not as a proof. Reporting it as the run's + * own failure keeps the page out of `running` forever, which is the one + * outcome a page must never reach. + * + * @type {(e: unknown) => BrowserTestReport} + */ + const infrastructureError = e => { + const [message, stack] = errorDetails(e) + const failure = moduleFailure('', performance.now() - start, message, stack) + announce(failure) + return reportOf(performance.now() - start, [failure], 'infrastructure-error') + } + // Both ways a run can fail as a runner end here. The error channel carries + // what an operation reported; the rejection carries what the interpreter + // could not answer at all — `asyncRun` panics on a command no handler + // claims, so a traversal or reporter that grew an operation this page does + // not implement arrives as a rejected promise rather than an `error`. + // Neither may escape: an unhandled rejection is a page stuck in `running` + // with no report and no completion event. return run(all).then(answer => { /** @type {Result} */ const outcome = /** @type {any} */ (answer) - // A failure here is the *runner* failing, not a proof: the traversal - // answers `ok` for every proof outcome, so the error channel carries - // only a dispatch failure — an operation this interpreter does not - // implement. Reporting it as the run's own failure keeps the page out - // of `running` forever, which is the one outcome a page must never - // reach. if (outcome[0] === 'error') { - const [message, stack] = errorDetails(outcome[1]) - const failure = moduleFailure('', performance.now() - start, message, stack) - announce(failure) - return reportOf(performance.now() - start, [failure], 'infrastructure-error') + return infrastructureError(outcome[1]) } // `allOk` answers in argument order, so the records are already in the // order the page passed its modules in, with each module's leaves in // structural order inside it. return reportOf(performance.now() - start, outcome[1]) - }) + }, infrastructureError) } /** @type {(root: Element) => (Window & { fjsBrowserTestReport?: Promise }) | null} */ diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index a7a143110..6e8dff40d 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -522,10 +522,12 @@ are shared. - [ ] Close each of those issues for both runners at once, so the two stay in sync rather than drifting from the day the core is shared. - [ ] Prove `runBrowserProofs`'s `infrastructure-error` branch — the run's own - dispatch failure, as opposed to any proof's. It is the one branch of the - page with no proof, and reaching it needs an effect the browser - interpreter does not implement, which the public entry point gives no way - to inject. + failure, as opposed to any proof's. It is the one branch of the page with + no proof, and reaching either half of it (an operation reporting through + the error channel, or one the interpreter cannot dispatch at all, which + rejects) needs an effect the public entry point gives no way to inject. + `effects/browser/proof.mjs` pins the interpreter's half — a command no + handler claims rejects — so what is left is the page's own guard. ### Related From 262729765b6d3538ee0426fa4d4f47c487389621 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:00:17 +0000 Subject: [PATCH 224/370] edag: two stale comments, and a changelog entry within the guideline Review findings on #1761. The two amnesia comments wrapped across lines, which is why the sweep missed them; the second sat directly above a short-arity example it contradicted. The changelog entry ran to 15 lines where changelog/README.md asks for about three (~250 characters) and puts rationale in the pull request description, where it already is. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- changelog/unreleased/1761.md | 19 ++++--------------- fjs/edag/amnesia/proof.f.mjs | 8 ++++---- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/changelog/unreleased/1761.md b/changelog/unreleased/1761.md index dd06eef3b..2cce4c6a7 100644 --- a/changelog/unreleased/1761.md +++ b/changelog/unreleased/1761.md @@ -1,15 +1,4 @@ -- **BREAKING CHANGES:** `edag`: a chain ends by **arity**, not by a `null` - terminator. A plain property read is `['.', a, 'b']`, a terminal call step - is `['|()', c]`, and `['|!()', c]` closes a region — each one element - shorter than before, with the continuation operand present only where a - chain actually continues. Every kind that can end is now an `or` of its two - closed arities, so `null` is a primitive again and has no reading in a - continuation position: graphs written against the old spelling - (`['.', a, 'b', null]`, `['|()', c, null]`) no longer validate, and the - `Dot`, `OptionDot`, `OptionCall`, `PropertyLambda`, `OptionLambda` and - `OptionPropertyLambda` types no longer admit them. Closedness by length is - what keeps a continuation from being smuggled onto a terminal, which is - what the explicit `null` used to guard; a trailing hole matches neither - arity and is rejected as before. `amnesia` reads a continuation by - destructuring everywhere, `skip` included, so an absent one never reaches - the prototype. +- **BREAKING CHANGES:** `edag`: a chain ends by arity, not a `null` terminator — + a plain read is `['.', a, 'b']` and a terminal call step `['|()', c]`, each one + element shorter. Graphs and types written against the old spelling + (`['.', a, 'b', null]`) no longer validate. diff --git a/fjs/edag/amnesia/proof.f.mjs b/fjs/edag/amnesia/proof.f.mjs index 9b70098ad..50f54f1f2 100644 --- a/fjs/edag/amnesia/proof.f.mjs +++ b/fjs/edag/amnesia/proof.f.mjs @@ -313,8 +313,8 @@ export const proof = { }, // The receiver is what a property step leaves behind, and it is // real rather than bookkeeping: `[42].at(0)` is `42` only because - // `at` is called *on* the array. A `.` node with a `null` - // continuation computes the same function value and drops it + // `at` is called *on* the array. A `.` node with no continuation + // operand computes the same function value and drops it // (`throw.detachedReceiver`) — the pair `chainsJs.receiver` makes in // JavaScript, made here by the nodes. receiver: () => { @@ -367,8 +367,8 @@ export const proof = { // call. Every case here has a counterpart under `chain` that throws for // exactly that reason. optionDot: () => { - // a?.b — the node's own step, which is the whole node when the - // continuation is `null`. Reading `a` and skipping the step would + // a?.b — the node's own step, which is the whole node at the + // shorter arity. Reading `a` and skipping the step would // evaluate to `a` itself, so these pin the index is applied. eq(['?.', ['{}', [[':', 'a', 7]]], 'a'], 7) // A closure is a value like any other — compared by `typeof`, since From 74202d5386bb8e7d7392ecf3e7ac3aeee37ee8ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:09:18 +0000 Subject: [PATCH 225/370] effects/browser: the runner keeps the effect's types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `browserRun` answered `(effect: unknown) => Promise`, so both call sites cast their way past it — the casts were the evidence. It is generic now, over the effect it accepts and the `Result` it resolves with, so an effect this runner cannot dispatch is a type error rather than a rejected promise. One cast remains, where `all` ties the loop through a self-reference, and it stops at that line. The page types its `report` handler and reads the outcome directly, and the interpreter's proofs narrow through the answered `Result` instead of indexing into `any`. The one deliberate cast left fabricates a command no handler claims, which is the panic that proof is about. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/module.mjs | 15 +++++++++-- fjs/effects/browser/proof.mjs | 44 ++++++++++++++++++++------------ fjs/emergent_testing/browser.mjs | 16 ++++++------ 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index 93f16f62d..ffdce9f02 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -24,7 +24,8 @@ * * @module * - * @import { Operation, ToAsyncOperationMap } from '../types.ts' + * @import { Effect, Operation, ToAsyncOperationMap } from '../types.ts' + * @import { All, Catch, Sandbox } from '../node/types.ts' * @import { Result } from '../../types/result/types.ts' */ @@ -90,9 +91,19 @@ const sandbox = async f => { * hole to be an ordinary outcome builds its runner on `partialMatch`, the way * `effects/mock` does. * - * @type {(extra: ToAsyncOperationMap) => (effect: unknown) => Promise} + * The runner it answers keeps the effect's own types: a caller reads the + * `Result` it resolves with rather than casting one out of `unknown`. The + * operations are the three below plus `extra`'s, which is what makes an effect + * this runner cannot dispatch a type error rather than a rejected promise. + * + * @template {Operation} O + * @param {ToAsyncOperationMap} extra + * @returns {(effect: Effect) => Promise>} */ export const browserRun = extra => { + // `all` interprets its children with the runner being defined, so the loop + // is tied through a self-reference and the map cannot be typed on the way + // in. The cast stops at this line: what the function answers is typed. /** @type {(effect: any) => Promise} */ const run = asyncRun(/** @type {any} */ ({ all: async (/** @type {readonly any[]} */ ...effects) => diff --git a/fjs/effects/browser/proof.mjs b/fjs/effects/browser/proof.mjs index bb24c2e8e..31535261a 100644 --- a/fjs/effects/browser/proof.mjs +++ b/fjs/effects/browser/proof.mjs @@ -3,6 +3,8 @@ * * It is a `.mjs` because the interpreter is: these run its real `try`/`catch`, * its real clock and its real `Promise.all`, which is the whole of what it is. + * + * @import { Result } from '../../types/result/types.ts' */ import { assert, assertEq } from '../../asserts/module.f.mjs' @@ -10,39 +12,49 @@ import { browserRun } from './module.mjs' import { all, catch_, sandbox } from '../node/module.f.mjs' import { do_ } from '../module.f.mjs' -/** @type {(effect: unknown) => Promise} */ -const run = browserRun(/** @type {any} */ ({})) +// No `extra`: these proofs exercise the three operations the interpreter has +// of its own. +const run = browserRun({}) + +/** + * The value a run answered, or the run's own failure as a panic — the runner + * answers `ok` for every one of these, so an `error` here is the proof + * failing. + * + * @type {(r: Result) => T} + */ +const okValue = r => { + if (r[0] !== 'ok') { throw r[1] } + return r[1] +} export const proof = { // A leaf that throws is an answer, not a failure of the run — the same // bargain `effects/node`'s `sandbox` makes. sandboxReportsAThrow: async () => { - const r = await run(sandbox(() => { throw 'boom' })) - assertEq(r[0], 'ok') - assertEq(r[1].result[0], 'error') - assertEq(r[1].result[1], 'boom') - assert(r[1].duration >= 0) + const { result, duration } = okValue(await run(sandbox(() => { throw 'boom' }))) + assertEq(result[0], 'error') + assertEq(result[1], 'boom') + assert(duration >= 0) }, // An asynchronous leaf is timed by what it did, not by how quickly it // handed back a promise. sandboxAwaitsAPromise: async () => { - const r = await run(sandbox(() => Promise.resolve(1))) - assertEq(r[1].result[1], 1) + assertEq(okValue(await run(sandbox(() => Promise.resolve(1)))).result[1], 1) }, catchAnswersTheThrownValue: async () => { - const r = await run(catch_(() => { throw 'thrown' })) - assertEq(r[0], 'ok') - assertEq(r[1][0], 'error') - assertEq(r[1][1], 'thrown') + const r = okValue(await run(catch_(() => { throw 'thrown' }))) + assertEq(r[0], 'error') + assertEq(r[1], 'thrown') }, // `all` answers in argument order however its children interleave, which is // what lets the shared traversal report in structural order. allAnswersInArgumentOrder: async () => { const slow = sandbox(() => new Promise(resolve => setTimeout(() => resolve('first'), 10))) const fast = sandbox(() => 'second') - const r = await run(all(slow, fast)) - assertEq(r[1][0][1].result[1], 'first') - assertEq(r[1][1][1].result[1], 'second') + const r = okValue(await run(all(slow, fast))) + assertEq(okValue(r[0]).result[1], 'first') + assertEq(okValue(r[1]).result[1], 'second') }, // A command no handler claims is a panic, not a `NotImplemented` answer: // this runner dispatches by exact match, which is why `browserRun` asks for diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 7e7e4b7ab..3c111603f 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -14,8 +14,8 @@ * with a stand-in root. * * @import { BrowserTestReport, Reporter, TestResult, _BrowserImporter, _BrowserReport, _BrowserTestResult, _TestAndPath } from './types.ts' - * @import { Effect, Func, IoChannel } from '../effects/types.ts' - * @import { All, Catch, Sandbox, SandboxResult } from '../effects/node/types.ts' + * @import { Effect, Func, ToAsyncOperationMap } from '../effects/types.ts' + * @import { All, Catch, IoChannel, Sandbox, SandboxResult } from '../effects/node/types.ts' * @import { Result } from '../types/result/types.ts' */ @@ -207,14 +207,16 @@ export const runBrowserProofs = (modules, result = () => undefined) => { // lands, in the position the module was passed in, exactly like a leaf. : mapStep(report(e[1]), r => /** @type {readonly _BrowserTestResult[]} */ ([r])) const all = mapStep(allOk(...prepared.map(runOne)), lists => lists.flat()) - const run = browserRun(/** @type {any} */ ({ + /** @type {ToAsyncOperationMap<_BrowserReport>} */ + const page = { // The page's end of the `report` operation: render as it lands, and // answer the record back so the traversal can keep it in order. - report: async (/** @type {_BrowserTestResult} */ r) => { + report: async r => { announce(r) return ok(r) }, - })) + } + const run = browserRun(page) /** * The run failed as a *runner*, not as a proof. Reporting it as the run's * own failure keeps the page out of `running` forever, which is the one @@ -235,9 +237,7 @@ export const runBrowserProofs = (modules, result = () => undefined) => { // not implement arrives as a rejected promise rather than an `error`. // Neither may escape: an unhandled rejection is a page stuck in `running` // with no report and no completion event. - return run(all).then(answer => { - /** @type {Result} */ - const outcome = /** @type {any} */ (answer) + return run(all).then(outcome => { if (outcome[0] === 'error') { return infrastructureError(outcome[1]) } From 34803792e1e24d88f92a1312d588f51ad13b882f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:17:21 +0000 Subject: [PATCH 226/370] effects/browser: assert through the shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proof's `okValue` read a run's answer behind a hand-written `if`/`throw`, which `fjs/AGENTS.md` §1.3 rules out: a local branch whose failing side never runs is a permanently uncovered branch, while `assertEq`'s own are covered where it lives. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/proof.mjs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/fjs/effects/browser/proof.mjs b/fjs/effects/browser/proof.mjs index 31535261a..5d335bdf9 100644 --- a/fjs/effects/browser/proof.mjs +++ b/fjs/effects/browser/proof.mjs @@ -17,15 +17,18 @@ import { do_ } from '../module.f.mjs' const run = browserRun({}) /** - * The value a run answered, or the run's own failure as a panic — the runner - * answers `ok` for every one of these, so an `error` here is the proof - * failing. + * The value a run answered. The runner answers `ok` for every one of these, so + * an `error` here is the proof failing — asserted through the shared helper, + * whose own branches are covered, rather than through a local `if`. * - * @type {(r: Result) => T} + * @template T + * @template E + * @param {Result} r + * @returns {T} */ const okValue = r => { - if (r[0] !== 'ok') { throw r[1] } - return r[1] + assertEq(r[0], 'ok', r) + return /** @type {T} */ (r[1]) } export const proof = { From 9631e06b8d57c7aff872c36052557704fbe75fc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:22:38 +0000 Subject: [PATCH 227/370] ci: hand the packed tarball to CI as an artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package is built in CI and then thrown away: node26 runs `npm pack` and nothing keeps the result. Checking the package as a consumer sees it needs a job with no repository checkout, and such a job can only receive the tarball through an artifact. Adds the producing half: `actions/upload-artifact` pinned at v7.0.1, and an upload step in node26 immediately after `npm pack`. The artifact name is an exported constant rather than a literal, so the consuming job that follows cannot drift from the producer. `if-no-files-found: error` is deliberate. The action's default is to warn and upload nothing, which would turn a packing failure into a missing-artifact failure in the consuming job — the wrong place, with the wrong cause. The proof covers the properties, and each was checked against a mutant that breaks it: the upload follows `npm pack` rather than preceding it (uploading first ships an empty artifact), the name matches the exported constant, the no-files behavior is `error`, and exactly one job uploads — a second producer under one name is a race, not redundancy. Owned by fjs/ci/todo/ci-integration-tests.md, whose plan already calls for the artifact publish step; the consuming job is the next step and is not here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 8 ++++++++ fjs/ci/config/module.f.mjs | 2 ++ fjs/ci/node/module.f.mjs | 16 ++++++++++++++++ fjs/ci/proof.f.mjs | 32 ++++++++++++++++++++++++++++++-- 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e76127682..7e66c13fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -509,6 +509,14 @@ }, { "run": "npm pack" + }, + { + "uses": "actions/upload-artifact@v7.0.1", + "with": { + "name": "package-tarball", + "path": "*.tgz", + "if-no-files-found": "error" + } } ] }, diff --git a/fjs/ci/config/module.f.mjs b/fjs/ci/config/module.f.mjs index ba7be1fe5..dfcfa8411 100644 --- a/fjs/ci/config/module.f.mjs +++ b/fjs/ci/config/module.f.mjs @@ -73,6 +73,8 @@ export const actions = /** @type {const} */({ 'actions/setup-node': 'v7.0.0', // https://github.com/marketplace/actions/cache 'actions/cache': 'v6.1.0', + // https://github.com/marketplace/actions/upload-a-build-artifact + 'actions/upload-artifact': 'v7.0.1', // https://github.com/marketplace/actions/setup-deno 'denoland/setup-deno': 'v2.0.5', // https://github.com/marketplace/actions/setup-bun diff --git a/fjs/ci/node/module.f.mjs b/fjs/ci/node/module.f.mjs index 57c6aaf58..20ffa63eb 100644 --- a/fjs/ci/node/module.f.mjs +++ b/fjs/ci/node/module.f.mjs @@ -12,6 +12,12 @@ import { node } from '../config/module.f.mjs' import { install, test, ubuntuArm, uses } from '../common/module.f.mjs' import { nixInstall, nixVersionCheckStep } from '../nix/module.f.mjs' +/** + * Name of the CI artifact carrying the `npm pack` tarball. The producing step + * is below; a consuming job downloads it by this name. + */ +export const packageArtifact = /** @type {const} */ ('package-tarball') + /** @type {(v: string) => string} */ export const major = v => v.split('.')[0] @@ -71,6 +77,16 @@ const node26Steps = [ test({ run: 'npx tsc' }), test({ run: 'npm run cov' }), test({ run: 'npm pack' }), + // Hands the tarball to a job that has no checkout, which is the only place + // the package can be checked as a consumer sees it. `if-no-files-found` + // must be `error`: the default warns and uploads nothing, so a consuming + // job would fail later on a missing artifact rather than here on the real + // cause. + test(uses('actions/upload-artifact', { + name: packageArtifact, + path: '*.tgz', + 'if-no-files-found': 'error', + })), ] /** @type {(steps: readonly MetaStep[]) => Job} */ diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index b0c869039..7fffab8a8 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -6,8 +6,8 @@ import { exitCode } from '../effects/node/module.f.mjs' import { ci, main } from './module.f.mjs' -import { functionalscript, node } from './config/module.f.mjs' -import { nodeNixJobs } from './node/module.f.mjs' +import { actions, functionalscript, node } from './config/module.f.mjs' +import { major, nodeNixJobs, packageArtifact } from './node/module.f.mjs' import { utf8, utf8ToString } from '../text/module.f.mjs' import { empty as emptyVec } from '../types/bit_vec/module.f.mjs' import { test, ubuntu, parseGitHubAction } from './common/module.f.mjs' @@ -222,6 +222,34 @@ export const proof = { assert(job['runs-on'] !== undefined, 'expected runs-on') assert(job.steps.length > 0, 'expected steps') }, + packageArtifact: () => { + const gha = run(false) + const job = gha.jobs[`node${major(node.default)}`] + assert(job !== undefined, 'expected the canonical Node job') + const packIndex = job.steps.findIndex(step => step.run === 'npm pack') + const uploadIndex = job.steps.findIndex( + step => step.uses === `actions/upload-artifact@${actions['actions/upload-artifact']}`) + assert(packIndex !== -1, 'expected npm pack') + assert(uploadIndex !== -1, 'expected the artifact upload') + // Uploading before packing would ship an empty artifact, and the + // failure would then surface in the consuming job rather than here, + // where the cause is. + assert(uploadIndex > packIndex, 'expected the upload to follow npm pack') + const upload = job.steps[uploadIndex]?.with + // Producer and consumer share the exported name rather than repeating + // a string literal that can drift apart. + assertEq(upload?.name, packageArtifact) + // The action's default is to warn and upload nothing, which would make + // a packing failure look like a consumer bug. + assertEq(upload?.['if-no-files-found'], 'error') + // One producer: a second upload under the same name is a race, not + // redundancy. + assertEq( + definedValues(gha.jobs).filter(j => + j.steps.some(step => step.uses?.startsWith('actions/upload-artifact@') === true)).length, + 1, + 'expected exactly one job to upload the package') + }, jobNeeds: () => { const steps = /** @type {const} */ ([{ run: 'echo hi' }]) /** @type {(jobs: Unknown) => Unknown} */ From 502744fb72345a71a8a44aff1f5a68b4fd9c57f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:25:02 +0000 Subject: [PATCH 228/370] ci: guard both halves of the `@module` placement rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fjs/AGENTS.md` §2 reserves `@module` for a package entry point. Nothing enforced it, and it drifted twice in a week: onto 102 `types.ts` and proof files, corrected in #1756, and again on a parallel branch that hit the same rule and filed it as debt rather than applying it. `tsc` cannot see the tag's placement, so the rule needs its own gate — the shape main established for the file-scope-typedef prohibition, in the same job. Both directions are checked, as two steps. A guard for stray tags alone would also pass on a tree that had lost the tag from every entry point, which is the failure the second half catches; the drift so far has been in the first direction only because nothing was looking in either. `grep -L` cannot carry the verdict in its exit status. It reports whether any file *matched*, not whether it listed one, so `xargs grep -L @module` exits 0 on a clean tree and the obvious spelling of the second guard can never fail. It pipes into `grep -q .` instead. The first guard's `grep -qv` has the right semantics as written. Each guard was proven against the string CI will actually run, extracted from the generated `ci.yml` rather than read off the generator source, and each fails on its own violation and no other: `@module` added to a `types.ts`, added to a `proof.f.mjs`, and removed from an entry point. The proof asserts both steps are generated, since a guard that silently stopped being emitted is precisely the failure it exists to prevent — and those assertions were themselves checked by removing each guard in turn. The existing typedef guard has no such assertion; left alone rather than widening this change. `fjs/ci/todo/node26-job-is-this-repo-only.md` records what this revealed: `fjs ci` is offered to other projects, but `node26` carries this repository's own gates — `npm run ci-update` against a script a consumer will not have, and now three convention checks. Pre-existing and not introduced here; naming it rather than fixing it inside this change. `npx tsc` clean, suite 3528/3528, `fjs/ci` proofs 29/29. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- .github/workflows/ci.yml | 6 ++ fjs/ci/node/module.f.mjs | 9 +++ fjs/ci/proof.f.mjs | 4 ++ fjs/ci/todo/node26-job-is-this-repo-only.md | 65 +++++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 fjs/ci/todo/node26-job-is-this-repo-only.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e76127682..c4106aab2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -501,6 +501,12 @@ { "run": "! grep -rnE '^(/\\*\\*.*@typedef|\\s\\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." }, + { + "run": "! grep -rl @module --include='*.ts' --include='*.mjs' --exclude-dir=node_modules . | grep -qvE '(^|/)module\\.(f\\.)?mjs$'" + }, + { + "run": "! git ls-files | grep -E '(^|/)module\\.(f\\.)?mjs$' | xargs grep -L @module | grep -q ." + }, { "run": "npx tsc" }, diff --git a/fjs/ci/node/module.f.mjs b/fjs/ci/node/module.f.mjs index 57c6aaf58..7b45b5760 100644 --- a/fjs/ci/node/module.f.mjs +++ b/fjs/ci/node/module.f.mjs @@ -68,6 +68,15 @@ const node26Steps = [ // `AGENTS.md`); `tsc` accepts one silently, so the prohibition needs its // own gate. test({ run: "! grep -rnE '^(/\\*\\*.*@typedef|\\s\\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." }), + // `@module` belongs to a package entry point and nowhere else + // (`fjs/AGENTS.md` §2). Both directions are checked: the tag drifted onto + // 102 `types.ts`/proof files before anything looked, and a check for only + // that half would also pass on a tree that had lost the tag everywhere. + // `grep -L` cannot carry the verdict in its exit status — it reports + // whether any file *matched*, not whether it listed one — so the second + // guard pipes into `grep -q .` instead. + test({ run: "! grep -rl @module --include='*.ts' --include='*.mjs' --exclude-dir=node_modules . | grep -qvE '(^|/)module\\.(f\\.)?mjs$'" }), + test({ run: "! git ls-files | grep -E '(^|/)module\\.(f\\.)?mjs$' | xargs grep -L @module | grep -q ." }), test({ run: 'npx tsc' }), test({ run: 'npm run cov' }), test({ run: 'npm pack' }), diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index b0c869039..5f8ba7395 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -108,6 +108,10 @@ export const proof = { assert(hasRunInJob('node26', 'npm pack')(gha), 'expected Node 26 package check') assert(hasRunInJob('node26', 'npm run ci-update')(gha), 'expected Node 26 workflow regeneration') assert(hasRunInJob('node26', 'git add -A && git diff --cached --exit-code')(gha), 'expected Node 26 generated-file drift check') + // Both halves of the `@module` rule, asserted separately: a guard that + // silently stopped being generated is the failure it exists to prevent. + assert(hasRunInJob('node26', 'grep -rl @module')(gha), 'expected Node 26 stray `@module` check') + assert(hasRunInJob('node26', 'xargs grep -L @module')(gha), 'expected Node 26 missing `@module` check') assert(!hasRun('npm publish --dry-run')(gha), 'unexpected npm publish dry-run') for (const id of /** @type {const} */ ([ 'ubuntu-intel', diff --git a/fjs/ci/todo/node26-job-is-this-repo-only.md b/fjs/ci/todo/node26-job-is-this-repo-only.md new file mode 100644 index 000000000..20ed98132 --- /dev/null +++ b/fjs/ci/todo/node26-job-is-this-repo-only.md @@ -0,0 +1,65 @@ +## node26-job-is-this-repo-only. `fjs ci` ships this repository's own gates + +**Priority:** P3 +**Status:** open + +### Problem + +`fjs ci` is offered to other projects as "FunctionalScript's default workflow" +([`fjs/README.md`](../../README.md)), and `ci(setup)` lets a caller vary only +`nodeExtra`, which reaches the per-OS platform jobs. The canonical Node jobs +come from `nodeVersionJobs` unconditionally, so every consumer's generated +`ci.yml` also gets the `node26` job — and that job is this repository's, not +theirs: + +- `npm run ci-update`, then `git add -A && git diff --cached --exit-code` — + regenerate-and-check-drift, against a script a consumer's `package.json` + very likely does not define, so the step fails outright; +- the file-scope JSDoc `@typedef` prohibition (root `AGENTS.md`); +- both halves of the `@module` placement rule (`fjs/AGENTS.md` §2), added by + the change that filed this issue. + +The last three encode *this repository's* conventions. A consumer who writes a +file-scope `@typedef`, or puts `@module` on a `types.ts`, has broken no rule of +their own, and their build fails telling them so. + +This is not a defect the `@module` guards introduced — `npm run ci-update` has +the same shape and predates them. What they did was make the pattern worth +naming: each convention added to `node26` widens the gap between what `fjs ci` +claims to generate and what it does. + +### Proposal + +No design agreed; the choice is what `fjs ci` is *for*. + +- **Split the job.** `nodeVersionJobs` yields the portable per-version jobs; + this repository's gates move to a `nodeExtra`-style hook it passes itself. + A consumer gets Node 22/24/26 running their tests and nothing else. +- **Or narrow the claim.** Keep the job as it is and say in `fjs/README.md` and + [`../README.md`](../README.md) that `fjs ci` generates *this* repository's + workflow, and that other projects should use `fjs run ` — + which `fjs/README.md` already offers as the escape hatch. + +The first is the better API and the second is honest about today's. Either +settles it; leaving both claims standing is what should not continue. + +Worth checking before choosing: whether any project outside this repository +actually runs `fjs ci`. If none does, the second option costs a paragraph and +the first is speculative generality. + +### Tasks + +- [ ] Decide which of the two the command is. +- [ ] Apply it, and make `fjs/README.md` and [`../README.md`](../README.md) + agree — today the first offers the command to other projects and the + second says the directory defines "the GitHub Actions workflow for this + repository". + +### Related + +- [`../node/module.f.mjs`](../node/module.f.mjs) — `node26Steps`, the job in + question. +- [`../module.f.mjs`](../module.f.mjs) — `ci(setup)` and `canonicalJobs`, where + the jobs are assembled and `nodeExtra` stops short. +- [`../README.md`](../README.md) — describes the generator as this + repository's; `fjs/README.md` offers it to others. From 668eee03b3402dd1f43ccd1af2026151e30d0df1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:28:41 +0000 Subject: [PATCH 229/370] effects/browser: an extra operation may not claim a core one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extra` was spread over the three operations the interpreter has of its own, so a handler named `sandbox`, `catch` or `all` replaced one silently — and the runner's answer is typed by those three, so a replacement made the type a lie. It panics on a collision instead. Letting the core win would have been just as silent in the other direction, discarding a handler written on purpose; a program claiming an operation this runner already has is the same class of bug as one asking for an operation it lacks, and the panic on that is what this runner already does. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/module.mjs | 21 +++++++++++++++++---- fjs/effects/browser/proof.mjs | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index ffdce9f02..847b42b73 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -103,9 +103,11 @@ const sandbox = async f => { export const browserRun = extra => { // `all` interprets its children with the runner being defined, so the loop // is tied through a self-reference and the map cannot be typed on the way - // in. The cast stops at this line: what the function answers is typed. + // in. The cast stops at the `asyncRun` call: what the function answers is + // typed. /** @type {(effect: any) => Promise} */ - const run = asyncRun(/** @type {any} */ ({ + let run + const core = { all: async (/** @type {readonly any[]} */ ...effects) => ok(await Promise.all(effects.map(run))), sandbox: async (/** @type {() => unknown} */ f) => ok(await sandbox(f)), @@ -116,7 +118,18 @@ export const browserRun = extra => { // dependency, so there is nothing here for a browser to do // differently. catch: async (/** @type {() => unknown} */ f) => ok(tryCatch(f)), - ...extra, - })) + } + // A collision panics rather than being resolved in either direction. The + // runner's answer is typed by these three operations, so an `extra` that + // replaced one would make the type a lie — and silently letting the core + // win instead would discard a handler the caller wrote on purpose. Neither + // is a routine outcome: it is a program claiming an operation this runner + // already has, which is the same class of bug as asking for one it does + // not. + const claimed = Object.keys(extra).filter(k => Object.hasOwn(core, k)) + if (claimed.length !== 0) { + throw `browserRun: ${claimed.join(', ')} already implemented` + } + run = asyncRun(/** @type {any} */ ({ ...core, ...extra })) return run } diff --git a/fjs/effects/browser/proof.mjs b/fjs/effects/browser/proof.mjs index 5d335bdf9..34928c623 100644 --- a/fjs/effects/browser/proof.mjs +++ b/fjs/effects/browser/proof.mjs @@ -10,6 +10,7 @@ import { assert, assertEq } from '../../asserts/module.f.mjs' import { browserRun } from './module.mjs' import { all, catch_, sandbox } from '../node/module.f.mjs' +import { ok } from '../../types/result/module.f.mjs' import { do_ } from '../module.f.mjs' // No `extra`: these proofs exercise the three operations the interpreter has @@ -59,6 +60,20 @@ export const proof = { assertEq(okValue(r[0]).result[1], 'first') assertEq(okValue(r[1]).result[1], 'second') }, + // The mirror of the panic below: a program that claims an operation this + // runner already implements is the same class of bug as one that asks for + // an operation it lacks. Resolving it either way would be silent — the + // answer's type would be a lie, or the caller's handler would be dropped. + collidingOperationIsRejected: async () => { + let message + // Side effect: `try`/`catch` is not allowed in FunctionalScript. + try { + browserRun(/** @type {any} */ ({ sandbox: async () => ok('replaced') })) + } catch (e) { + message = e + } + assertEq(message, 'browserRun: sandbox already implemented') + }, // A command no handler claims is a panic, not a `NotImplemented` answer: // this runner dispatches by exact match, which is why `browserRun` asks for // a complete map of the operations it is given. A host that wants a hole to From a98bbb84beae92855fc4711cd2ccf064e6e1289b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:32:32 +0000 Subject: [PATCH 230/370] emergent_testing: the todo says one thing about the operation list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 recorded the measured answer and left the paragraph that called the same three operations unsettled and predicted two of them would move. An implementer reading it got two designs, one of which this work disproved. The surviving paragraph says what was expected and why it was wrong — "a browser proof run needs a clock and dynamic import" is true of the page and false of the effect set — rather than restating a question the measurement answered. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/share-browser-console-runner.md | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 6e8dff40d..fd1df6f79 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -142,18 +142,16 @@ and is reviewable without the next one. [node-module-layering](../../effects/todo/node-module-layering.md) carries the same answer. - **Three of that list are unsettled, and this step does not get to assume - them.** `all`, `await` and `sandbox` are agreed: - [node-module-layering](../../effects/todo/node-module-layering.md) moves - them too. But that issue keeps `Now`, `Fetch` and `Import` in - `effects/node` on a reader-benefit argument, and this step was written - listing all three as moving. Neither was written knowing the fact that - decides it — which operations the step-5 interpreter actually implements — - so step 5 settles them and updates both files in the same change. The - expectation recorded there: `now` and `import` move (a browser proof run - needs a clock and dynamic import), `fetch` stays (nothing in the shared - runner performs one, and DESIGN.md §4 extracts at the second *real* - consumer). + **The expectation this step was written with was wrong, which is why the + list was measured rather than argued.** `all`, `await` and `sandbox` were + agreed all along. `Now`, `Fetch` and `Import` were not: this step listed + all three as moving, on the reasoning that a browser proof run needs a + clock and dynamic import. That is true of the *page* and false of the + *effect set* — the page reads its own clock and calls its own importer, + in the impure shell where host values belong, and neither reaches the + interpreter as an operation. Reasoning from what a host *can* do + predicted one answer; reading what the interpreter had to implement gave + another. **The vocabulary went first, and it was not speculative.** Before an operation can move, the types it is *declared in* have to have a home: From cb4080080cfd175ec92d334a7260532aa190755f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:39:29 +0000 Subject: [PATCH 231/370] effects/browser: carry extra handlers by descriptor Spreading `extra` copied only its own enumerable properties, while `match` looks a handler up with `getOwnPropertyDescriptor`. A map that declared a handler non-enumerable therefore dispatched under the node runner and rejected under this one, for no reason either of them states. The handlers are carried over by descriptor now, so this runner accepts exactly what the layer's dispatch accepts. An inherited handler is out of contract in `match` and stays out of contract here; the collision check reads own names for the same reason. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/module.mjs | 11 +++++++++-- fjs/effects/browser/proof.mjs | 13 +++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index 847b42b73..61fd4088b 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -126,10 +126,17 @@ export const browserRun = extra => { // is a routine outcome: it is a program claiming an operation this runner // already has, which is the same class of bug as asking for one it does // not. - const claimed = Object.keys(extra).filter(k => Object.hasOwn(core, k)) + const claimed = Object.getOwnPropertyNames(extra).filter(k => Object.hasOwn(core, k)) if (claimed.length !== 0) { throw `browserRun: ${claimed.join(', ')} already implemented` } - run = asyncRun(/** @type {any} */ ({ ...core, ...extra })) + // The handlers are carried over by descriptor rather than by spread, so a + // map that declares one non-enumerable keeps it. `match` looks a handler up + // with `getOwnPropertyDescriptor`, so this runner now accepts exactly what + // the layer's dispatch already accepts — no more, and no less. An + // inherited handler is out of contract there and stays out of contract + // here. + run = asyncRun(/** @type {any} */ ( + Object.defineProperties({ ...core }, Object.getOwnPropertyDescriptors(extra)))) return run } diff --git a/fjs/effects/browser/proof.mjs b/fjs/effects/browser/proof.mjs index 34928c623..cfc66cf28 100644 --- a/fjs/effects/browser/proof.mjs +++ b/fjs/effects/browser/proof.mjs @@ -60,6 +60,19 @@ export const proof = { assertEq(okValue(r[0]).result[1], 'first') assertEq(okValue(r[1]).result[1], 'second') }, + // `match` looks a handler up by own-property descriptor, so an `extra` that + // declares one non-enumerable is still a valid operation map. Carrying the + // handlers over by spread would have dropped it and turned a dispatch this + // layer supports into a rejected promise. + nonEnumerableHandlerIsDispatched: async () => { + const extra = Object.defineProperty({}, 'quiet', { + value: async () => ok('answered'), + enumerable: false, + }) + const r = await browserRun(/** @type {any} */ (extra))( + /** @type {any} */ (do_('quiet'))()) + assertEq(okValue(r), 'answered') + }, // The mirror of the panic below: a program that claims an operation this // runner already implements is the same class of bug as one that asks for // an operation it lacks. Resolving it either way would be silent — the From bbc64335ee6f516df69a80562b0a9ebc980ac8ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:40:02 +0000 Subject: [PATCH 232/370] todo: plan the JSON/DataJS/FunctionalScript restructure A coordinating issue for restructuring the parser/serializer stack into three tiers: a self-contained JSON codec, a new spec'd DataJS interchange format (JSON extended from tree to DAG, nothing else) in fjs/media/datajs, and the current fjs/djs front end moving to fjs/fsc to grow with the language. Records the design decision log (';'-terminated consts, JSON whitespace, JS-derived duplicate-key semantics, special-number round-trips, ASCII const names, subset laws DataJS < FJS < JS), the staged migration sequence, and the edits owed to the five existing issues it supersedes or rebases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 261 ++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 todo/parser-serializer-restructure.md diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md new file mode 100644 index 000000000..4f656c20f --- /dev/null +++ b/todo/parser-serializer-restructure.md @@ -0,0 +1,261 @@ +## Restructure JSON, DataJS, and FunctionalScript parsers/serializers + +**Priority:** P2 +**Status:** open + +This is a coordinating issue: it records the design decided in discussion, +sequences the stages, and names the edits owed to existing issues. Each stage +gets its own co-located `todo/` file when it starts; concrete tasks live there, +not here. + +### Problem + +Parsing and serialization are spread over four module families whose +relationships grew rather than being designed: + +- **`fjs/media/json`** — the structural parser (one container machine with a + `NumberPolicy` seam, standard and extended codecs) and serializer are JSON's + own and recently reworked. Its *tokenizer* is not: it is a ~100-line adapter + over `fjs/js/tokenizer`, the hand-written 747-line JavaScript tokenizer. +- **`fjs/djs`** — a full module pipeline: a grammar-based BNF tokenizer, a BNF + parser over token symbols, `AstModule`, and the transpiler behind + `fjs compile`. It conflates two different things: a data interchange format + (values, `const` sharing) and the language front end (imports, comments, + identifier keys, future expressions). +- **`fjs/fsc`** — nearly empty: a character-classifier stub plus a dead third + copy of the JSON grammar + ([orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md)). +- **`fjs/bnf`** — the grammar toolkit, still evolving (EOF encoding change, + pending unicode split). + +Two structural problems follow: + +1. **The media codecs sit downstream of permanently evolving code.** JSON and + the data format depend on the JS token vocabulary (`fjs/js/tokenizer`), + which must grow with FunctionalScript. A frozen interchange format cannot + be built on a mutating lexer, and the same argument bars a runtime + dependency on `fjs/bnf` until that module is stable. +2. **The data format and the compiler front end are one codebase.** The DJS + pipeline cannot be promoted to a spec'd, "implement it in an afternoon" + format while it also carries module framing, trivia, and the growth path of + the language. + +### Proposal + +Three tiers, each self-contained, with dependency arrows pointing only at +spec-frozen layers: + +```text +fjs/media/json own tokenizer + parser frozen by the JSON spec + ▲ +fjs/media/datajs reuses JSON's pieces frozen by the DataJS spec + (strings, numbers, containers) + +fjs/fsc JS tokenizer (comments, all evolves with the language + operators) → parser → AST → EDAG +``` + +- **JSON**: accepted language and value semantics are frozen; the tokenizer + becomes self-contained (the `fjs/js/tokenizer` wrapper is replaced by a + small scanner of JSON's own lexical grammar). Error shapes may change once + in that swap; accepted-input behavior and proofs do not. +- **DataJS** (the format known in this repository as DJS): a new, minimal, + spec'd format — JSON extended from a tree to a DAG, nothing else. New + hand-written parser and serializer in `fjs/media/datajs`, layered on JSON's + exported pieces. Everything that is not needed for the DAG property moves to + FunctionalScript. +- **FunctionalScript**: the current `fjs/djs` front end (grammar-based + tokenizer, BNF parser, AST, transpiler) moves to `fjs/fsc` and continues to + grow there — comments, imports, identifier keys, and the staged EDAG work. + The compiler can emit DataJS (normalized) or JSON. +- **BNF is not a runtime dependency of the media codecs.** The spec carries + the grammars as BNF text; `fjs/bnf/**` may hold the JSON and DataJS grammars + as *proof-covered examples* cross-checked against the spec's test vectors. + An example grammar without proof coverage is how + [orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md) happened; + none may be added without proofs. + +### The DataJS format (decision record) + +Decisions made in design discussion; the spec (stage 1) is their normative +home. The governing principle: **derive behavior from JS**. DataJS ⊂ +FunctionalScript ⊂ JavaScript, where `⊂` means *accepted with identical +meaning* — a subset may reject what its superset accepts, but must never +accept something and mean something different by it. + +**Name.** DataJS; "DJS" survives only as an informal abbreviation. Not +"DataScript" (taken by a well-known database library). The npm name `datajs` +belongs to a defunct OData library — check availability before any standalone +package publishes; the spec does not need it. + +**Data model.** A DAG of values. Leaves are JSON's primitives plus `bigint`, +`undefined`, `NaN`, `Infinity`, `-Infinity`, `-0`. Number round-trips satisfy +`Object.is`. Object entries follow JS duplicate-key semantics exactly: value +from the last occurrence, position from the first. Sharing is semantic — two +references to one `const` denote the same node, and references may only point +at *earlier* consts, so a document is acyclic by construction and parseable in +one pass. The reference parser returns live JS values and does not freeze them +(FunctionalScript has no `Object.freeze`); the spec is silent on freezing and +other implementations may. + +**Syntax.** + +```text +module ::= const* export +const ::= 'const' id '=' value ';' +export ::= 'export' 'default' value (no trailing ';') +value ::= primitive | id | array | object +key ::= string | '[' '"__proto__"' ']' +``` + +- **`;` terminates every `const`;** no `;` after `export default`, no empty + statements. Rationale, each sufficient alone: no line-terminator taxonomy in + the spec (a lone CR *is* a JS `LineTerminator` — trivia no implementer + should need); one canonical spelling per document; the separator is a + visible character, so byte-different files that render identically cannot + differ in meaning; and a document minifies to one line — + `const a=[];export default[a,a]` — enabling DataJS inside JSON strings, + line-delimited streaming, and one-line test fixtures. Whitespace is needed + only between adjacent word-tokens (`const a`, `export default x`). +- **Whitespace is JSON's** — space, tab, LF, CR — insignificant everywhere. + Other JS whitespace (U+2028/U+2029, NBSP, FF, BOM) is rejected. +- **No comments, no imports.** A DataJS document is closed; the compiler + inlines resolved imports when normalizing FunctionalScript to DataJS. +- **Strings and numbers are JSON's grammar**, plus the bigint `n` suffix. + `-` is not an operator: it folds into a following number, bigint, or + `Infinity` token only (`-NaN`, `-undefined`, a bare `-` are rejected). +- **Keys** are JSON strings, plus the computed spelling `["__proto__"]` as the + only way to write that one key; a bare or string `"__proto__"` key is + rejected (JS would read it as prototype replacement). +- **Const names** are ASCII: `[A-Za-z_$][A-Za-z0-9_$]*`, each bound once, + and binding `undefined`, `NaN`, or `Infinity` is rejected — JS permits + `const undefined = 5` and later `undefined` then means the const, which a + subset treating it as a literal would silently reinterpret. +- **A JSON document is a valid DataJS value, never a DataJS document** (a + DataJS document is a JS module, so it cannot be a JSON document). The + conversion is literal: `"export default " + json + ";"` — minus the `;`, + `"export default " + json` — is always a valid document. + +**Serialization.** Any conforming serializer may emit any valid document; a +separate *normalized form* section defines one byte-deterministic canonical +serializer (const names `_0`, `_1`, … in first-emission order; a const emitted +iff its value is referenced more than once; shortest round-trip number +spelling; bigints as full digits + `n`; fixed string escaping). Normalization +is not a blocker for the format spec. The serializer cannot delegate numbers +to `JSON.stringify` (it loses `-0` and non-finite values); DataJS owns its +number writer. Whether the canonical layout is fully minified or one statement +per line is decided in the spec stage. + +**Extensions.** Recognized: `.data.js`, `.data.mjs`, `.d.js`, `.d.mjs`. +Emitted and canonical: `.data.js` (`.data.mjs` where unambiguous ESM +resolution matters). No `.f` combinations — `.f.[m]js` marks FunctionalScript +source, and every DataJS document is compiler-accepted by construction, so a +combined marker would encode a redundant fact. + +### FunctionalScript consequences + +- **`;` is required in early-stage FunctionalScript**, matching DataJS. This + removes ASI — including its future "no LineTerminator here" restricted + productions — before the expression grammar grows the hazards (`(`, `[` at + line start). Relaxing later to also accept newline termination is + backward-compatible; the reverse would be breaking, so strict-first is the + safe ratchet. Repository `.f.mjs` source is unaffected (it is parsed by + Node/TypeScript); the cost lands at `.f.mjs` → `.f.js` migration, where the + normalizer inserts `;` mechanically — `.f.js` is compiler-formatted, not + hand-formatted. +- **`undefined`, `NaN`, `Infinity` become FunctionalScript reserved words**, + so the DataJS binding restriction is inherited rather than special-cased. +- The moved parser's separator rule changes from newline to `';'` (the moved + tokenizer's operator vocabulary gains `;`). +- Subset laws are proof obligations, not prose: every DataJS *accept* vector + parses in FunctionalScript to the same value graph; the normalizer closes + the loop (`parse_datajs(normalize(m))` equals the evaluation of any + data-only module `m`); FunctionalScript fixtures remain valid JS with + identical meaning (checked against a real JS engine in proofs). + +### Stages + +Each stage lands green and independently; `fjs compile` keeps working +throughout. + +1. **Spec** — `spec/datajs/`: format spec (grammar as BNF text, data model, + rationale) plus the normalization section, and the conformance test + vectors (accept, reject, round-trip) that every later stage runs against. + Decides the two deferred details: canonical layout, media type. +2. **Dead code** — delete `fjs/fsc/bnf.f.mjs` and `fjs/fsc/json.f.mjs`, or + convert the salvageable parts into proof-covered `fjs/bnf/**` examples. + Resolves [orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md). +3. **JSON self-contained tokenizer** — replace the `fjs/js/tokenizer` wrapper + in `fjs/media/json/tokenizer` with a scanner of JSON's own lexical + grammar, exporting the string and number scanners for reuse. + Accepted-input proofs unchanged; error-shape proofs rewritten once. +4. **`fjs/media/datajs`** — parser (JSON's container machine via its policy + seam, plus an identifier policy) and serializer (the shared walker of + [157](../fjs/djs/todo/157-json-djs-shared-value-machine.md) §2 with a + ref-lookup hook, own number writer), proofs over the spec vectors. +5. **Front-end move** — `fjs/djs/{tokenizer,parser,ast,transpiler}` → + `fjs/fsc/*` as a rename; separator `nl` → `';'`; reserved words added; + `fjs compile` repointed. The EDAG staging + ([compile-modules-to-edag](../fjs/djs/todo/compile-modules-to-edag.md)) + continues under the `fsc` name. +6. **Compiler output** — the normalizer: data-only FunctionalScript (imports + resolved and inlined) to normalized DataJS or JSON, with the subset-law + proofs above. +7. **Cleanup** — retire `fjs/js/tokenizer` when its last consumer is gone + (`fjs/js/string_escape` and `fjs/js/keywords` remain as shared, + JS-spec-frozen tables); one clean-break release with the standard + `**BREAKING CHANGES:**` changelog treatment for the removed `fjs/djs/*` + paths and changed serializer output — no compatibility shims. + +### Tasks + +- [ ] Stage 1: write `spec/datajs/` and the conformance vectors; file its + co-located todo. +- [ ] Stage 2: resolve + [orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md). +- [ ] Stage 3: JSON self-contained tokenizer; file its todo under + `fjs/media/json/todo/`. +- [ ] Stage 4: `fjs/media/datajs`; file its todo. +- [ ] Stage 5: front-end move to `fjs/fsc`; file its todo. +- [ ] Stage 6: normalizer + subset-law proofs; file its todo. +- [ ] Stage 7: `fjs/js/tokenizer` retirement and the breaking-change release. +- [ ] Update affected issues as their subject matter moves (see below). +- [ ] `npx tsc`, `fjs test` at every stage. + +### Edits owed to existing issues + +- [157-json-djs-shared-value-machine](../fjs/djs/todo/157-json-djs-shared-value-machine.md) + — §2's shared-walker extraction becomes stage 4 work; §3's minus-rewriter + question is settled by stage 3 (the folding lives in JSON's own tokenizer + and DataJS reuses it). Rebase the issue on this plan or fold it in. +- [663-json-djs-tree-type](../fjs/djs/todo/663-json-djs-tree-type.md) — the + shared `Tree

` instantiation targets `fjs/media/datajs`; rename paths. +- [bnf-grammar-single-owner](../fjs/media/json/todo/bnf-grammar-single-owner.md) + — re-scope: the canonical JSON grammar's owner is the spec (text) plus a + proof-covered `fjs/bnf` example, not a runtime module; the + `fjs/djs/tokenizer` pointer becomes the `fsc` tokenizer. +- [compile-modules-to-edag](../fjs/djs/todo/compile-modules-to-edag.md) — its + paths move `djs` → `fsc` in stage 5; its special-number round-trip + requirement is satisfied by the DataJS spec rather than DJS-specific + patches. +- [orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md) — + resolved by stage 2. +- `fjs/djs/README.md` and the remaining `fjs/djs/todo/*` files — move with + their subject matter in stage 5; the DJS name in them refers to the moved + front end, not to DataJS. + +### Related + +- [`fjs/media/json/README.md`](../fjs/media/json/README.md) — the policy-seam + parser design DataJS layers on. +- [`fjs/djs/parser/README.md`](../fjs/djs/parser/README.md) — the front end + that moves to `fjs/fsc`. +- [`todo/edag-stage1-discussion.md`](./edag-stage1-discussion.md), + [`todo/edag-spec.md`](./edag-spec.md) — EDAG semantics the moved front end + compiles to; serialized EDAG spells object constructors as arrays, so + DataJS's JS-derived object semantics do not conflict with EDAG's ordered + entries. +- [`fjs/fsc/README.md`](../fjs/fsc/README.md) — the `.f.mjs` → `.f.js` + migration where the `;` requirement lands. +- [`todo/migrate-typescript-to-mjs.md`](./migrate-typescript-to-mjs.md) — the + repository-wide source migration this plan slots into. From affbc2f4fa6dc6d1a56c19b32043ccf51134ec95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:47:11 +0000 Subject: [PATCH 233/370] effects/browser: read the extra map once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collision check enumerated `extra` and the map construction enumerated it again, so a proxy could hide a core key from the first reading and reveal it to the second — replacing a built-in handler behind the check's back, and making the runner's answer type a lie. Enumerating is user code, and it may answer differently each time. The descriptors are read once now, and both the check and the map are built from that reading — the same mistake, and the same fix, as the page's double read of a proof export earlier in this PR. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/module.mjs | 23 ++++++++++++++--------- fjs/effects/browser/proof.mjs | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index 61fd4088b..92a456588 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -126,17 +126,22 @@ export const browserRun = extra => { // is a routine outcome: it is a program claiming an operation this runner // already has, which is the same class of bug as asking for one it does // not. - const claimed = Object.getOwnPropertyNames(extra).filter(k => Object.hasOwn(core, k)) + // `extra` is read **once**, and the check and the map are built from that + // one reading. Enumerating is a user-observable operation — a proxy decides + // what it answers, and may answer differently the second time — so a check + // that read it again could approve a map the runner does not build, which + // is the same mistake the page made about proof exports. + // + // The handlers are carried over by descriptor rather than by spread, so a + // map that declares one non-enumerable keeps it. `match` looks a handler up + // with `getOwnPropertyDescriptor`, so this runner accepts exactly what the + // layer's dispatch already accepts — no more, and no less. An inherited + // handler is out of contract there and stays out of contract here. + const handlers = Object.getOwnPropertyDescriptors(extra) + const claimed = Object.keys(handlers).filter(k => Object.hasOwn(core, k)) if (claimed.length !== 0) { throw `browserRun: ${claimed.join(', ')} already implemented` } - // The handlers are carried over by descriptor rather than by spread, so a - // map that declares one non-enumerable keeps it. `match` looks a handler up - // with `getOwnPropertyDescriptor`, so this runner now accepts exactly what - // the layer's dispatch already accepts — no more, and no less. An - // inherited handler is out of contract there and stays out of contract - // here. - run = asyncRun(/** @type {any} */ ( - Object.defineProperties({ ...core }, Object.getOwnPropertyDescriptors(extra)))) + run = asyncRun(/** @type {any} */ (Object.defineProperties({ ...core }, handlers))) return run } diff --git a/fjs/effects/browser/proof.mjs b/fjs/effects/browser/proof.mjs index cfc66cf28..f3f6c264b 100644 --- a/fjs/effects/browser/proof.mjs +++ b/fjs/effects/browser/proof.mjs @@ -60,6 +60,28 @@ export const proof = { assertEq(okValue(r[0]).result[1], 'first') assertEq(okValue(r[1]).result[1], 'second') }, + // Enumerating `extra` runs user code too: a proxy may answer one set of + // keys and then another. Reading it once means the map the runner builds is + // the map the collision check approved — here the second reading's + // `sandbox` is never seen at all, so the core handler stands rather than + // being replaced behind the check's back. + twoFacedExtraCannotReplaceACoreHandler: async () => { + let reads = 0 + const extra = new Proxy({}, { + ownKeys: () => { + reads += 1 + return reads === 1 ? [] : ['sandbox'] + }, + getOwnPropertyDescriptor: () => ({ + value: async () => ok('replaced'), + configurable: true, + enumerable: true, + }), + }) + const r = okValue(await browserRun(/** @type {any} */ (extra))(sandbox(() => 42))) + assertEq(reads, 1) + assertEq(okValue(r.result), 42) + }, // `match` looks a handler up by own-property descriptor, so an `extra` that // declares one non-enumerable is still a valid operation map. Carrying the // handlers over by spread would have dropped it and turned a dispatch this From bb14a50cb8b4731bad687fc6851c271dc4b2013a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:52:40 +0000 Subject: [PATCH 234/370] emergent_testing: record where progress rendering would belong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review asked whether deleting the browser's batch size costs the page its progress rows. It does defer them: leaves resolve through microtasks, so a run drains without a paint. The deleted constant is not the answer — this file already records that its yield had no paint boundary where it claimed one — and the traversal is the wrong place to look for one, since what to paint and when is the page's own concern. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/share-browser-console-runner.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index fd1df6f79..d7546f397 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -519,6 +519,15 @@ are shared. [hostile-proof-values](./hostile-proof-values.md). - [ ] Close each of those issues for both runners at once, so the two stay in sync rather than drifting from the day the core is shared. +- [ ] Decide where progress rendering belongs, if a suite ever grows large + enough for it to matter. Leaves resolve through microtasks, so a run + drains without a paint and the rows a page appends as results land only + become visible when it ends. The deleted `batchSize` is not the answer — + this file already records that its `setTimeout` yield had no paint + boundary where it claimed one — and the traversal is the wrong place to + look for one either way: what to paint, and when, is the page's own + concern, so a renderer that batches its DOM writes is where this lands if + anything does. - [ ] Prove `runBrowserProofs`'s `infrastructure-error` branch — the run's own failure, as opposed to any proof's. It is the one branch of the page with no proof, and reaching either half of it (an operation reporting through From b926e297b23440a40c49569e9e83ff4df45d675b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 20:58:27 +0000 Subject: [PATCH 235/370] ci: pin the upload glob in the proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The path was the one property of the upload step nothing asserted. Reproduced the gap before fixing it: changing '*.tgz' to '*.wrong' left the suite fully green. if-no-files-found: error bounds the damage but does not close it — it catches a glob matching nothing, and the surviving case is a glob matching the wrong files, which uploads them quietly under the right artifact name. That is worse than an empty artifact, because the consuming job then type-checks something real and unrelated. assertEq on the path closes it; the same mutant now fails. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/proof.f.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index 7fffab8a8..3209943e3 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -239,6 +239,10 @@ export const proof = { // Producer and consumer share the exported name rather than repeating // a string literal that can drift apart. assertEq(upload?.name, packageArtifact) + // The glob has to match what `npm pack` writes. `if-no-files-found` + // catches a glob that matches *nothing*; a glob matching the *wrong* + // files would upload them quietly, so pin it. + assertEq(upload?.path, '*.tgz') // The action's default is to warn and upload nothing, which would make // a packing failure look like a consumer bug. assertEq(upload?.['if-no-files-found'], 'error') From 3af1ec6dd79b5fd493ad70e177777db4774da6df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:12:16 +0000 Subject: [PATCH 236/370] ci: match the `@module` tag, not the bare string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard failed CI on its own commit. It matched `@module` anywhere in a `.ts`/`.mjs` file, and `fjs/ci/proof.f.mjs` — added in the same change — contains the string three times, in the assertions naming these very steps. So the guard flagged the code that asserts the guard exists. My verification ran in the wrong order and that is the whole lesson here: I extracted the emitted commands and exercised them against three violations, then added the proof assertions, and never re-ran the guards afterwards. Everything I checked was correct when I checked it. The final tree was never checked at all. The pattern now matches the tag where JSDoc puts it — the same anchoring the file-scope-typedef guard uses — so a line that mentions `@module` in a string or a `//` comment is not a declaration of it. That distinction is what the rule was always about; the bare string only happened to work while nothing in the repository discussed the tag. Both guards also end in a plain `grep` rather than `grep -q`. The `-q` form exits on its first match and closes the pipe, so the failing run printed `grep: write error: Broken pipe` and no indication of which file was at fault. Without it the log names the offending paths. Six cases now run against the strings extracted from the regenerated `ci.yml`, after every edit: the clean tree; a file that only mentions the tag (must not fire); a real tag on a `types.ts` and on a `proof.f.mjs` (must fire); an entry point stripped of its tag (must fire); and a failing run, checked for naming the file. The proof assertions match `grep -rlE` and `xargs grep -LE`, the parts that tell the two steps apart, so neither can be satisfied by the other's step — re-checked by removing each guard in turn. `npx tsc` clean, suite 3528/3528, `fjs/ci` proofs 29/29. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- .github/workflows/ci.yml | 4 ++-- fjs/ci/node/module.f.mjs | 16 +++++++++++----- fjs/ci/proof.f.mjs | 6 ++++-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4106aab2..3a35fd583 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -502,10 +502,10 @@ "run": "! grep -rnE '^(/\\*\\*.*@typedef|\\s\\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." }, { - "run": "! grep -rl @module --include='*.ts' --include='*.mjs' --exclude-dir=node_modules . | grep -qvE '(^|/)module\\.(f\\.)?mjs$'" + "run": "! grep -rlE '^(/\\*\\*.*@module|\\s\\* *@module)' --include='*.ts' --include='*.mjs' --exclude-dir=node_modules . | grep -vE '(^|/)module\\.(f\\.)?mjs$' | grep ." }, { - "run": "! git ls-files | grep -E '(^|/)module\\.(f\\.)?mjs$' | xargs grep -L @module | grep -q ." + "run": "! git ls-files | grep -E '(^|/)module\\.(f\\.)?mjs$' | xargs grep -LE '^(/\\*\\*.*@module|\\s\\* *@module)' | grep ." }, { "run": "npx tsc" diff --git a/fjs/ci/node/module.f.mjs b/fjs/ci/node/module.f.mjs index 7b45b5760..a6203558f 100644 --- a/fjs/ci/node/module.f.mjs +++ b/fjs/ci/node/module.f.mjs @@ -72,11 +72,17 @@ const node26Steps = [ // (`fjs/AGENTS.md` §2). Both directions are checked: the tag drifted onto // 102 `types.ts`/proof files before anything looked, and a check for only // that half would also pass on a tree that had lost the tag everywhere. - // `grep -L` cannot carry the verdict in its exit status — it reports - // whether any file *matched*, not whether it listed one — so the second - // guard pipes into `grep -q .` instead. - test({ run: "! grep -rl @module --include='*.ts' --include='*.mjs' --exclude-dir=node_modules . | grep -qvE '(^|/)module\\.(f\\.)?mjs$'" }), - test({ run: "! git ls-files | grep -E '(^|/)module\\.(f\\.)?mjs$' | xargs grep -L @module | grep -q ." }), + // + // The pattern matches the tag where JSDoc puts it, not the bare string, + // which also appears in code *about* the rule — the assertions in + // `../proof.f.mjs` name these very steps. Two further traps: `grep -L` + // reports whether any file *matched*, not whether it listed one, so it + // cannot carry the verdict in its exit status and the second guard pipes + // into a final `grep`; and that final `grep` omits `-q` on purpose, so a + // failing run names the offending files in the log instead of printing + // nothing but a broken pipe. + test({ run: "! grep -rlE '^(/\\*\\*.*@module|\\s\\* *@module)' --include='*.ts' --include='*.mjs' --exclude-dir=node_modules . | grep -vE '(^|/)module\\.(f\\.)?mjs$' | grep ." }), + test({ run: "! git ls-files | grep -E '(^|/)module\\.(f\\.)?mjs$' | xargs grep -LE '^(/\\*\\*.*@module|\\s\\* *@module)' | grep ." }), test({ run: 'npx tsc' }), test({ run: 'npm run cov' }), test({ run: 'npm pack' }), diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index 5f8ba7395..e9c0a9fb4 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -110,8 +110,10 @@ export const proof = { assert(hasRunInJob('node26', 'git add -A && git diff --cached --exit-code')(gha), 'expected Node 26 generated-file drift check') // Both halves of the `@module` rule, asserted separately: a guard that // silently stopped being generated is the failure it exists to prevent. - assert(hasRunInJob('node26', 'grep -rl @module')(gha), 'expected Node 26 stray `@module` check') - assert(hasRunInJob('node26', 'xargs grep -L @module')(gha), 'expected Node 26 missing `@module` check') + // Matched on the part that distinguishes them rather than the shared + // pattern, so neither assertion can be satisfied by the other's step. + assert(hasRunInJob('node26', 'grep -rlE')(gha), 'expected Node 26 stray `@module` check') + assert(hasRunInJob('node26', 'xargs grep -LE')(gha), 'expected Node 26 missing `@module` check') assert(!hasRun('npm publish --dry-run')(gha), 'unexpected npm publish dry-run') for (const id of /** @type {const} */ ([ 'ubuntu-intel', From 1cbbb0e72004ed0d0c1138602efe53e03ea7f25a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:14:31 +0000 Subject: [PATCH 237/370] todo: apply review findings to the restructure plan Three review findings on the DataJS decision record, each a subset-law hole: const names must also exclude JavaScript's reserved words in module code (const class = 1 is a JS syntax error); the JSON-to-DataJS textual conversion must rewrite a bare "__proto__" key to the computed spelling rather than claiming plain concatenation is always valid; and bigint is its own digits-only production, not an 'n' suffix on the JSON number grammar (JS rejects 1.5n and 1e2n). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 33 +++++++++++++++++++-------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 4f656c20f..5cdf7e4bc 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -121,20 +121,33 @@ key ::= string | '[' '"__proto__"' ']' Other JS whitespace (U+2028/U+2029, NBSP, FF, BOM) is rejected. - **No comments, no imports.** A DataJS document is closed; the compiler inlines resolved imports when normalizing FunctionalScript to DataJS. -- **Strings and numbers are JSON's grammar**, plus the bigint `n` suffix. - `-` is not an operator: it folds into a following number, bigint, or - `Infinity` token only (`-NaN`, `-undefined`, a bare `-` are rejected). +- **Strings and numbers are JSON's grammar.** Bigint is a production of its + own, not a suffix on the number grammar: JSON's integer part (no fraction, + no exponent, no leading zeros) followed by `n` — JS rejects `1.5n` and + `1e2n`, so "number + `n`" would over-accept. `-` is not an operator: it + folds into a following number, bigint, or `Infinity` token only (`-NaN`, + `-undefined`, a bare `-` are rejected). - **Keys** are JSON strings, plus the computed spelling `["__proto__"]` as the only way to write that one key; a bare or string `"__proto__"` key is rejected (JS would read it as prototype replacement). - **Const names** are ASCII: `[A-Za-z_$][A-Za-z0-9_$]*`, each bound once, - and binding `undefined`, `NaN`, or `Infinity` is rejected — JS permits - `const undefined = 5` and later `undefined` then means the const, which a - subset treating it as a literal would silently reinterpret. -- **A JSON document is a valid DataJS value, never a DataJS document** (a - DataJS document is a JS module, so it cannot be a JSON document). The - conversion is literal: `"export default " + json + ";"` — minus the `;`, - `"export default " + json` — is always a valid document. + minus two exclusion sets. JavaScript's reserved words as they apply to a + binding identifier in module code (module code is strict), including + `import`, `export`, `let`, `yield`, `await`, and `static`, are excluded — + `const class = 1` is a JS syntax error, so accepting it would break the + subset law. Binding `undefined`, `NaN`, or `Infinity` is additionally + rejected — JS *permits* `const undefined = 5` and later `undefined` then + means the const, which a subset treating it as a literal would silently + reinterpret. The spec enumerates the excluded words exhaustively rather + than citing ECMA-262. +- **Every JSON value is a DataJS value; no JSON document is a DataJS + document** (a DataJS document is a JS module, so it cannot be a JSON + document). The textual conversion `"export default " + json` yields a + valid document with one exception: a bare `"__proto__"` object key — + rejected by DataJS because JS reads it as prototype replacement — must be + rewritten to the computed spelling `["__proto__"]` during conversion. + Plain concatenation is exactly valid for JSON containing no `__proto__` + key. **Serialization.** Any conforming serializer may emit any valid document; a separate *normalized form* section defines one byte-deterministic canonical From 083f6cf9e016a28d7e91c09287c5de7248ead21d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:17:24 +0000 Subject: [PATCH 238/370] todo: const names also exclude strict-mode eval and arguments Review follow-up: eval and arguments are not reserved words, but module code is strict and rejects binding them, so the exclusion set is "every name JS rejects as a binding identifier in module code", not "reserved words" alone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 5cdf7e4bc..34f2e353e 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -131,11 +131,13 @@ key ::= string | '[' '"__proto__"' ']' only way to write that one key; a bare or string `"__proto__"` key is rejected (JS would read it as prototype replacement). - **Const names** are ASCII: `[A-Za-z_$][A-Za-z0-9_$]*`, each bound once, - minus two exclusion sets. JavaScript's reserved words as they apply to a - binding identifier in module code (module code is strict), including - `import`, `export`, `let`, `yield`, `await`, and `static`, are excluded — - `const class = 1` is a JS syntax error, so accepting it would break the - subset law. Binding `undefined`, `NaN`, or `Infinity` is additionally + minus two exclusion sets. Every name JavaScript rejects as a binding + identifier in module code (module code is strict) is excluded: the + reserved words, including `import`, `export`, `let`, `yield`, `await`, + and `static`, and the strict-mode-only bindings `eval` and `arguments` — + `const class = 1` and `const eval = 1` are JS syntax errors there, so + accepting either would break the subset law. Binding `undefined`, `NaN`, + or `Infinity` is additionally rejected — JS *permits* `const undefined = 5` and later `undefined` then means the const, which a subset treating it as a literal would silently reinterpret. The spec enumerates the excluded words exhaustively rather From f898fc4715e738736794ecdbf3aa9a31cc2a64ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:21:31 +0000 Subject: [PATCH 239/370] rtti: bound a const container before reading its members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate(exp) on fjs/edag was exponential in nesting depth: a chain node is an or() of its two closed arities, and constContainerValidate read every declared member before checking the tuple's length, so each arm walked the shared exp operand before either could fail. A 16-deep chain of continued dots took 1.9s; method chaining nests exactly that way. Both readers now check fits() before the member walk. The gate cannot change acceptance — the same check is re-asked after the reads, so it only rejects earlier — and parse gates identically, which keeps the two reporting the same error. Measured over 1550 schema/value pairs against the previous implementation: zero acceptance differences, and every error-path difference paired between the readers. What changes is precedence: a value that is both too long and wrong at a member is now answered by its shape rather than by the member, pinned as structuralMismatchIsAnsweredFirst. Chains are linear again: 400 levels in 31ms where 16 took 1897ms. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/rtti/parse/module.f.mjs | 17 +++++++++++++++++ fjs/rtti/validate/module.f.mjs | 17 +++++++++++++++++ fjs/rtti/validate/proof.f.mjs | 16 ++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 4351c69b6..94294441b 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -340,6 +340,23 @@ const constContainerParse = if (!isContainer(value)) { return verror('unexpected value') } + // Bound the container before reading any member: a value the + // schema cannot fit is rejected whatever its members hold, so + // walking them first only decides *which* error to report. The + // same check is re-asked below, after the reads, so a value that + // changes under them is still caught — this only ever rejects + // earlier, never accepts more. + // + // It is load-bearing for an `or` of two arities, the shape a + // schema uses to say a trailing operand may be left out + // (`fjs/edag`'s chain nodes). Without it each arm walks the + // shared operands before failing on length, so validating a + // nested chain costs 2^depth; with it the arm is decided before + // any recursion. `parse` gates identically, which is what keeps + // the two readers reporting the same error. + if (!fits(value, declared.length)) { + return verror('unexpected value') + } const r = eachEntry( rttiEntries, (k, t) => { diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index 5fd40c9d5..76b3ffc29 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -215,6 +215,23 @@ const constContainerValidate = if (!isContainer(value)) { return verror('unexpected value') } + // Bound the container before reading any member: a value the + // schema cannot fit is rejected whatever its members hold, so + // walking them first only decides *which* error to report. The + // same check is re-asked below, after the reads, so a value that + // changes under them is still caught — this only ever rejects + // earlier, never accepts more. + // + // It is load-bearing for an `or` of two arities, the shape a + // schema uses to say a trailing operand may be left out + // (`fjs/edag`'s chain nodes). Without it each arm walks the + // shared operands before failing on length, so validating a + // nested chain costs 2^depth; with it the arm is decided before + // any recursion. `parse` gates identically, which is what keeps + // the two readers reporting the same error. + if (!fits(value, declared.length)) { + return verror('unexpected value') + } const r = eachEntry( rttiEntries, (k, v) => { diff --git a/fjs/rtti/validate/proof.f.mjs b/fjs/rtti/validate/proof.f.mjs index d60243162..570a98e38 100644 --- a/fjs/rtti/validate/proof.f.mjs +++ b/fjs/rtti/validate/proof.f.mjs @@ -861,6 +861,22 @@ export const proof = { assertOk(read([/** @type {const} */ (42)])([42])) } }, + // A value the schema cannot fit is answered by its **shape**, before any + // member is read: both readers report the container-level error rather + // than the first bad member, and they report it identically. That + // precedence is what lets a container be bounded before recursing, which + // an `or` of two arities needs — see the comment on the gate in + // `./module.f.mjs`. Acceptance is untouched: the same check is re-asked + // after the reads, so the gate only ever rejects earlier. + structuralMismatchIsAnsweredFirst: () => { + const t = /** @type {const} */ ([42]) + // too long *and* wrong at index 0 — the length is what answers + for (const read of [v, p]) { assertErrorPath([])(read(t)([43, 'extra'])) } + // a member error alone still reports the member + for (const read of [v, p]) { assertErrorPath(['0'])(read(t)([43])) } + // and a value that fits is read as before + for (const read of [v, p, d]) { assertOk(read(t)([42])) } + }, // The walk is bounded by what the value and its prototypes carry rather // than by `length`: a sparse array as long as the index space allows // answers at once, where materializing the range exhausted memory first. From b33029dc3f244f137ba2f60ce5c297cc0d0debd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:22:48 +0000 Subject: [PATCH 240/370] todo: const hoisting by identity, stage-5 numeric leaves and changelog Three review findings: normalized-form const hoisting is restricted to objects/arrays counted by reference identity, so a value-equality ref counter can never merge 0 with -0 or mishandle NaN; stage 5 gains the front-end work for NaN/Infinity/-Infinity/-0 (unresolved identifiers in today's parser), without which the stage-6 subset proofs reject DataJS accept vectors; and stage 5's syntax change carries its own BREAKING CHANGES changelog entry rather than deferring it to stage 7. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 29 ++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 34f2e353e..5d3134223 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -154,7 +154,11 @@ key ::= string | '[' '"__proto__"' ']' **Serialization.** Any conforming serializer may emit any valid document; a separate *normalized form* section defines one byte-deterministic canonical serializer (const names `_0`, `_1`, … in first-emission order; a const emitted -iff its value is referenced more than once; shortest round-trip number +iff its value is an object or array referenced more than once **by reference +identity** — primitives are always emitted inline and never hoisted, since +primitive sharing is unobservable and a value-equality ref counter would +face the `0`/`-0` and `NaN` merging ambiguity that the `Object.is` +round-trip guarantee forbids; shortest round-trip number spelling; bigints as full digits + `n`; fixed string escaping). Normalization is not a blocker for the format spec. The serializer cannot delegate numbers to `JSON.stringify` (it loses `-0` and non-finite values); DataJS owns its @@ -210,17 +214,28 @@ throughout. ref-lookup hook, own number writer), proofs over the spec vectors. 5. **Front-end move** — `fjs/djs/{tokenizer,parser,ast,transpiler}` → `fjs/fsc/*` as a rename; separator `nl` → `';'`; reserved words added; - `fjs compile` repointed. The EDAG staging - ([compile-modules-to-edag](../fjs/djs/todo/compile-modules-to-edag.md)) - continues under the `fsc` name. + the DataJS numeric leaves taught to the moved front end — `NaN`, + `Infinity`, `-Infinity`, and exact `-0` are unresolved identifiers in + today's parser, so reserving the names alone would *reject* DataJS accept + vectors: tokenizer, grammar, minus-folding, and AST/evaluation support is + stage-5 work (the front-end half of + [compile-modules-to-edag](../fjs/djs/todo/compile-modules-to-edag.md)'s + special-number requirement), a precondition of stage 6's subset proofs; + `fjs compile` repointed. The EDAG staging continues under the `fsc` + name. This stage changes accepted public `.f.js` syntax (statement + termination, newly reserved names), so its own PR carries the + `**BREAKING CHANGES:**` changelog treatment for that behavior — it is + not deferred to stage 7. 6. **Compiler output** — the normalizer: data-only FunctionalScript (imports resolved and inlined) to normalized DataJS or JSON, with the subset-law proofs above. 7. **Cleanup** — retire `fjs/js/tokenizer` when its last consumer is gone (`fjs/js/string_escape` and `fjs/js/keywords` remain as shared, - JS-spec-frozen tables); one clean-break release with the standard - `**BREAKING CHANGES:**` changelog treatment for the removed `fjs/djs/*` - paths and changed serializer output — no compatibility shims. + JS-spec-frozen tables); the clean-break release with `**BREAKING + CHANGES:**` changelog treatment for the removed `fjs/djs/*` paths and + changed serializer output — no compatibility shims. (Each earlier stage + that changes public behavior, stage 5 in particular, carries its own + breaking-change entry in its own PR, per the changelog convention.) ### Tasks From 4182601cc196022a8a3d20b95ca770e6b2fc47dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:23:53 +0000 Subject: [PATCH 241/370] todo: name every fjs/djs destination; EOF change already shipped Review follow-ups from the human approval: stage 5 now states where the rest of fjs/djs lands (serializer and value-tree types into stage 4's fjs/media/datajs, examples and the top-level compile() module with the front end to fsc), and the compile-modules-to-edag edit note distinguishes its front-end paths (djs -> fsc) from its serializer citation (-> media/ datajs). The bnf EOF-encoding change is cited as shipped (#1516) rather than listed as still pending. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 5d3134223..0f623c0e0 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -25,8 +25,10 @@ relationships grew rather than being designed: - **`fjs/fsc`** — nearly empty: a character-classifier stub plus a dead third copy of the JSON grammar ([orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md)). -- **`fjs/bnf`** — the grammar toolkit, still evolving (EOF encoding change, - pending unicode split). +- **`fjs/bnf`** — the grammar toolkit, still evolving: a breaking + EOF-encoding change shipped recently + ([#1516](https://github.com/functionalscript/functionalscript/pull/1516)), + and the unicode split is still pending. Two structural problems follow: @@ -213,7 +215,13 @@ throughout. [157](../fjs/djs/todo/157-json-djs-shared-value-machine.md) §2 with a ref-lookup hook, own number writer), proofs over the spec vectors. 5. **Front-end move** — `fjs/djs/{tokenizer,parser,ast,transpiler}` → - `fjs/fsc/*` as a rename; separator `nl` → `';'`; reserved words added; + `fjs/fsc/*` as a rename. The rest of `fjs/djs` has stated destinations + rather than following the rename: `serializer/` is reworked into stage + 4's `fjs/media/datajs` (it does not move to `fsc`); the value-tree types + in `fjs/djs/types.ts` go with it, per + [663](../fjs/djs/todo/663-json-djs-tree-type.md); `examples/` and the + top-level `module.f.mjs`/`proof.f.mjs` carrying `compile()` move with + the front end to `fsc`. Separator `nl` → `';'`; reserved words added; the DataJS numeric leaves taught to the moved front end — `NaN`, `Infinity`, `-Infinity`, and exact `-0` are unresolved identifiers in today's parser, so reserving the names alone would *reject* DataJS accept @@ -265,7 +273,9 @@ throughout. proof-covered `fjs/bnf` example, not a runtime module; the `fjs/djs/tokenizer` pointer becomes the `fsc` tokenizer. - [compile-modules-to-edag](../fjs/djs/todo/compile-modules-to-edag.md) — its - paths move `djs` → `fsc` in stage 5; its special-number round-trip + front-end paths move `djs` → `fsc` in stage 5, while its serializer + citation (`../serializer/module.f.mjs`) follows the serializer into + stage 4's `fjs/media/datajs`; its special-number round-trip requirement is satisfied by the DataJS spec rather than DJS-specific patches. - [orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md) — From c4beaa82e49185e7390dc4eb34b4935d16a06502 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:27:21 +0000 Subject: [PATCH 242/370] changelog: entry for the const-container length gate Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- changelog/unreleased/1766.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/unreleased/1766.md diff --git a/changelog/unreleased/1766.md b/changelog/unreleased/1766.md new file mode 100644 index 000000000..dbb6bc38f --- /dev/null +++ b/changelog/unreleased/1766.md @@ -0,0 +1,4 @@ +- `rtti`: `validate` and `parse` bound a closed tuple or struct by length before + reading its members, so an `or` of two arities no longer walks shared operands + once per arm. Acceptance is unchanged; a value that is both too long and wrong + at a member now reports the container-level error rather than the member's. From 1932280516036285c5c4852d89417a5b4e1ab185 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:27:35 +0000 Subject: [PATCH 243/370] ci: drop the `@module` grep guards; no external tools without approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the two generated CI steps and their proof assertions. The CI generator, its proof and `.github/workflows/ci.yml` are byte-identical to `main` again. The guards were the argument against themselves. A pattern over source text cannot tell a JSDoc tag from the same characters in a string or a comment, so the first flagged `fjs/ci/proof.f.mjs` — the file whose assertions named the guard — and the second could not have reported a missing tag in any file that mentioned the tag anywhere. Anchoring the pattern fixed both instances without fixing the category: the check still only approximated the question it claimed to answer, and its failures were silent ones. AGENTS.md gains §6. No external tool is called from our code without approval first; where a rule needs real analysis the answer is a tool that parses what it checks, ESLint or Clippy, proposed and approved rather than a pattern that resembles one. And leaving the check undone beats the machinery: a rule no available tool can express stays written down and unenforced, which is honest and cheaper. That makes the `@module` rule unenforced again — deliberately. It drifted twice, which argued for a gate, but the gate has to be worth trusting. Two things left standing, both open to reversal: `fjs/ci/todo/node26-job-is-this-repo-only.md` stays. It records that `fjs ci` is offered to other projects while `node26` carries this repository's own gates — `npm run ci-update` hard-references this tree, and `Setup.nodeExtra` does not reach the canonical jobs. Pre-existing, and the Codex P1 on the reverted change was about the same seam. The file-scope `@typedef` guard is still generated. It is the same shape as what this reverts, so §6 covers it, but removing it was not asked and is a separate call. `npx tsc` clean, suite 3533/3533, `fjs/ci` proofs 30/30. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- .github/workflows/ci.yml | 6 ------ AGENTS.md | 24 ++++++++++++++++++++++++ fjs/ci/node/module.f.mjs | 15 --------------- fjs/ci/proof.f.mjs | 6 ------ 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41ca9f4f5..7e66c13fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -501,12 +501,6 @@ { "run": "! grep -rnE '^(/\\*\\*.*@typedef|\\s\\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." }, - { - "run": "! grep -rlE '^(/\\*\\*.*@module|\\s\\* *@module)' --include='*.ts' --include='*.mjs' --exclude-dir=node_modules . | grep -vE '(^|/)module\\.(f\\.)?mjs$' | grep ." - }, - { - "run": "! git ls-files | grep -E '(^|/)module\\.(f\\.)?mjs$' | xargs grep -LE '^(/\\*\\*.*@module|\\s\\* *@module)' | grep ." - }, { "run": "npx tsc" }, diff --git a/AGENTS.md b/AGENTS.md index 2ac2a2693..89412ae8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ actually touches its subject. 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) +6. [External tools](#6-external-tools) --- @@ -123,3 +124,26 @@ 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). + +## 6. External tools + +**Do not call an external tool from our code — a CI step, a script, a +generator — without approval first.** `grep`, `sed`, `awk` and their kin +included. + +Text matching is not analysis. A pattern over source text cannot tell a JSDoc +tag from the same characters inside a string or a comment, so a check built on +one returns confident answers it has no basis for. A `grep` guard for `@module` +placement flagged the very file whose assertions named the guard, and its +companion could not have seen a missing tag in any file that mentioned the tag +anywhere — a check that cannot fail is indistinguishable from one that passes. +Where a rule needs real analysis, the answer is an established tool that parses +what it checks — ESLint for JavaScript, Clippy for Rust — proposed and approved +before it is added, never a pattern that approximates one. + +**Leaving the check undone is the better trade against that complexity.** A +rule no available tool can express stays written down and unenforced. That is +honest, and cheaper than machinery whose failures are silent. + +Keep simple tasks simple; a script earns its place only where the task genuinely +is not. Instances predating this rule are not precedent for new ones. diff --git a/fjs/ci/node/module.f.mjs b/fjs/ci/node/module.f.mjs index e201ef2f5..20ffa63eb 100644 --- a/fjs/ci/node/module.f.mjs +++ b/fjs/ci/node/module.f.mjs @@ -74,21 +74,6 @@ const node26Steps = [ // `AGENTS.md`); `tsc` accepts one silently, so the prohibition needs its // own gate. test({ run: "! grep -rnE '^(/\\*\\*.*@typedef|\\s\\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." }), - // `@module` belongs to a package entry point and nowhere else - // (`fjs/AGENTS.md` §2). Both directions are checked: the tag drifted onto - // 102 `types.ts`/proof files before anything looked, and a check for only - // that half would also pass on a tree that had lost the tag everywhere. - // - // The pattern matches the tag where JSDoc puts it, not the bare string, - // which also appears in code *about* the rule — the assertions in - // `../proof.f.mjs` name these very steps. Two further traps: `grep -L` - // reports whether any file *matched*, not whether it listed one, so it - // cannot carry the verdict in its exit status and the second guard pipes - // into a final `grep`; and that final `grep` omits `-q` on purpose, so a - // failing run names the offending files in the log instead of printing - // nothing but a broken pipe. - test({ run: "! grep -rlE '^(/\\*\\*.*@module|\\s\\* *@module)' --include='*.ts' --include='*.mjs' --exclude-dir=node_modules . | grep -vE '(^|/)module\\.(f\\.)?mjs$' | grep ." }), - test({ run: "! git ls-files | grep -E '(^|/)module\\.(f\\.)?mjs$' | xargs grep -LE '^(/\\*\\*.*@module|\\s\\* *@module)' | grep ." }), test({ run: 'npx tsc' }), test({ run: 'npm run cov' }), test({ run: 'npm pack' }), diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index b01cdf8d9..3209943e3 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -108,12 +108,6 @@ export const proof = { assert(hasRunInJob('node26', 'npm pack')(gha), 'expected Node 26 package check') assert(hasRunInJob('node26', 'npm run ci-update')(gha), 'expected Node 26 workflow regeneration') assert(hasRunInJob('node26', 'git add -A && git diff --cached --exit-code')(gha), 'expected Node 26 generated-file drift check') - // Both halves of the `@module` rule, asserted separately: a guard that - // silently stopped being generated is the failure it exists to prevent. - // Matched on the part that distinguishes them rather than the shared - // pattern, so neither assertion can be satisfied by the other's step. - assert(hasRunInJob('node26', 'grep -rlE')(gha), 'expected Node 26 stray `@module` check') - assert(hasRunInJob('node26', 'xargs grep -LE')(gha), 'expected Node 26 missing `@module` check') assert(!hasRun('npm publish --dry-run')(gha), 'unexpected npm publish dry-run') for (const id of /** @type {const} */ ([ 'ubuntu-intel', From cab8ce0888f3d476ebcc3f1a1a0131ababfaf013 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:28:22 +0000 Subject: [PATCH 244/370] effects/browser: give the thread back on a frame budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting `batchSize = 25` deleted the yield with it, and the yield was load-bearing. Leaves resolve through microtasks, and a microtask drain never returns to the event loop, so the whole suite ran as one task: no paint, no timer, no click from the first proof to the last. Measured in Chromium on this repo's browser suite, a single 54.7 s task with nothing rendered, long enough for the browser to offer to kill the page. The interpreter's `sandbox` now hands the thread back when 8 ms of it have been spent — what a 60 Hz frame leaves for script. A budget rather than a count of proofs, because a count cannot know what it costs, and in the interpreter rather than the traversal, because staying responsive is a fact about a browser and about no other host. Not `all`: it must start every child before awaiting any, so pausing between children hangs a graph whose child waits on a later sibling. The check answers `null` when the slice has room rather than a settled promise, and that is the mechanism rather than a detail. A leaf runs synchronously inside its handler, which is what staggers the children; await anything first and every handler reads the budget at the same instant, before any leaf has run, so none of them ever sees it spent. The first shape of this fix did exactly that and changed nothing. After: longest task 98 ms on a first run and none over 50 ms on a second, 392 frames against 14, 194 progress updates against 4, 3456 rows painted as they land, wall clock 52.2 s against 52.8 s. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/1759.md | 6 +- fjs/effects/browser/module.mjs | 95 ++++++++++++++++++- fjs/effects/browser/proof.mjs | 28 ++++++ .../todo/share-browser-console-runner.md | 69 ++++++++++---- 4 files changed, 176 insertions(+), 22 deletions(-) diff --git a/changelog/unreleased/1759.md b/changelog/unreleased/1759.md index 725035191..b5ce78b1a 100644 --- a/changelog/unreleased/1759.md +++ b/changelog/unreleased/1759.md @@ -1,4 +1,4 @@ - **BREAKING CHANGES:** `emergent_testing`: `runModuleMap` answers the run's - outcome — totals and leaf records — not an exit code, which `exitCodeOf` - derives. The browser page runs the same traversal as `fjs t`, through a new - browser interpreter + outcome — totals and leaf records — not an exit code; `exitCodeOf` derives + that. The browser page shares `fjs t`'s traversal, through an interpreter + that yields on a frame budget diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index 92a456588..44b34a885 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -33,6 +33,46 @@ import { asyncRun } from '../module.mjs' import { error, ok } from '../../types/result/module.f.mjs' import { tryCatch } from '../../types/result/module.mjs' +/** + * How long a run may hold the thread before handing it back, in milliseconds. + * + * It is a *frame* budget rather than a count of proofs, and that difference is + * the whole point. A count cannot know what it costs: twenty-five trivial + * leaves are nothing and twenty-five heavy ones are still a freeze, which is + * why the number this replaces was indefensible. 8 ms is what a 60 Hz frame + * leaves for script, so a page that respects it gets a paint slot at the rate + * it can actually use one, whatever its proofs happen to cost. + */ +const frameBudget = 8 + +/** + * Hands control back to the host's event loop and comes back in a later task. + * + * Not `setTimeout`: it clamps to 4 ms once nested, and this is called between + * leaves, so the clamp would add minutes to a suite of a few thousand. That + * clamp is what pushed an earlier attempt to `MessageChannel` and then into a + * failure under bun, which drains port messages before running a due timer — + * a problem this module does not have, because nothing but a browser runs it. + * + * `scheduler.yield` is the primitive built for exactly this and does not + * clamp; `MessageChannel` is the same idea by hand where it is missing. + * + * @type {() => Promise} + */ +const yieldToHost = () => { + const { scheduler } = /** @type {{ scheduler?: { yield?: () => Promise } }} */ ( + /** @type {unknown} */ (globalThis)) + if (scheduler?.yield !== undefined) { return scheduler.yield() } + return new Promise(resolve => { + const { port1, port2 } = new MessageChannel() + port1.onmessage = () => { + port1.close() + resolve(undefined) + } + port2.postMessage(undefined) + }) +} + /** * Calls `f` and answers what happened — its value, or the value it threw — * together with how long it took. @@ -107,10 +147,63 @@ export const browserRun = extra => { // typed. /** @type {(effect: any) => Promise} */ let run + // When this run last gave the thread back, and the yield the leaves over + // budget are all waiting on. Per runner rather than per module, because the + // thread is one thing however many runs share it. + let sliceStart = performance.now() + /** @type {Promise | null} */ + let slice = null + /** + * The yield this leaf must wait for, or `null` when the slice has room. + * + * **Answering `null` rather than an already-resolved promise is the whole + * mechanism**, and it took a measurement to learn it. A leaf runs + * synchronously inside its handler, so `all`'s children start one after + * another as each previous leaf finishes — which is what makes "has this + * slice been spent?" a question with a moving answer. Await anything before + * the leaf, even a resolved promise, and every handler asks the question at + * the same instant, before any leaf has run: all of them see an empty + * budget, none of them yields, and the run is one task again. + * + * Waiters share one yield instead of each taking a task, and re-ask when it + * resolves: the first few resume into the fresh slice and run inline, and + * whichever one finds the budget spent again waits for the next. + * + * @type {() => Promise | null} + */ + const overBudget = () => { + if (performance.now() - sliceStart < frameBudget) { return null } + if (slice === null) { + slice = yieldToHost().then(() => { + slice = null + sliceStart = performance.now() + }) + } + return slice + } const core = { all: async (/** @type {readonly any[]} */ ...effects) => ok(await Promise.all(effects.map(run))), - sandbox: async (/** @type {() => unknown} */ f) => ok(await sandbox(f)), + // **The leaf is where a browser run yields, and it has to be.** `all` + // starts every child before awaiting any — a contract, not an + // implementation detail — so it cannot pause between them without + // hanging a graph whose child waits on a later sibling. That leaves the + // leaf: it is the one point every unit of work passes through, and it + // holds no sibling's answer while it waits. + // + // Without this the whole suite runs as one task. Leaves resolve through + // microtasks, and a microtask drain never returns to the event loop, so + // a page cannot paint a result, service a timer or answer a click from + // the first proof to the last — measured at ~53 s on this repo's own + // browser suite, long enough for the browser to offer to kill the page. + sandbox: async (/** @type {() => unknown} */ f) => { + let wait = overBudget() + while (wait !== null) { + await wait + wait = overBudget() + } + return ok(await sandbox(f)) + }, // No clock and no fixture convention — see `Catch` in // `../node/types.ts` for why this is a second operation beside // `sandbox` rather than a use of it. It is `tryCatch`, spelled the diff --git a/fjs/effects/browser/proof.mjs b/fjs/effects/browser/proof.mjs index f3f6c264b..244260b4c 100644 --- a/fjs/effects/browser/proof.mjs +++ b/fjs/effects/browser/proof.mjs @@ -60,6 +60,34 @@ export const proof = { assertEq(okValue(r[0]).result[1], 'first') assertEq(okValue(r[1]).result[1], 'second') }, + // **The page must stay alive while a suite runs.** Leaves resolve through + // microtasks, and a microtask drain never returns to the event loop, so + // without a yield the whole suite is one task: no paint, no timer, no + // click, for as long as it takes. This posts a message before the run and + // asks whether it was delivered while the run was still going — under a + // single-task run it cannot be, because nothing else gets a turn until the + // run is over. + theThreadIsGivenBackDuringARun: async () => { + // Well over the frame budget, so the second leaf finds it spent. + const burn = () => { + const end = performance.now() + 25 + while (performance.now() < end) { /* hold the thread */ } + } + let deliveredDuringRun = false + let finished = false + const { port1, port2 } = new MessageChannel() + port1.onmessage = () => { deliveredDuringRun = !finished } + port2.postMessage(undefined) + // Its own runner, so the slice starts here: the first leaf runs inline + // and the second finds the budget spent, which is the moment the thread + // has to come back. + const r = okValue(await browserRun({})(all(sandbox(burn), sandbox(burn), sandbox(burn)))) + finished = true + port1.close() + port2.close() + assertEq(r.length, 3) + assert(deliveredDuringRun) + }, // Enumerating `extra` runs user code too: a proxy may answer one set of // keys and then another. Reading it once means the map the runner builds is // the map the collision check approved — here the second reading's diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index d7546f397..cadc0d637 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -68,8 +68,16 @@ made first-opener-wins; the yield needed `MessageChannel` rather than `setTimeout` only because `setTimeout` clamps to 4 ms once nested; and the `MessageChannel` proof then failed under bun, which drains port messages before running a due timer. Six rounds of review, every one of them downstream of a -constant that was finally deleted. The end state — no batch size at all — is -the state that copying `fjs t` would have produced on day one. +constant that was finally deleted. + +**And "no batch size" is not "no yielding" — this file said so badly enough to +mislead a later reader, which was me.** Copying `fjs t` exactly *does* freeze a +page: without a yield the whole suite runs as one task, measured at 54.7 s on +this repo's own browser suite, and the line above about the batching having no +paint boundary is about a bug in that attempt rather than a finding that the +yield did nothing. What the browser needs is a turn, on a budget it can defend +— a frame — and what it never needed was a number of proofs. Step 5's task list +records where that landed and what it measured. **A problem the browser reveals is not a browser problem.** Two came up, and both are properly issues rather than fixes inside a port: @@ -214,10 +222,13 @@ and is reviewable without the next one. `Reporter` and an interpreter, and the traversal does the rest. **The batching went with it**, as this file said it should be decided - rather than inherited: `batchSize = 25` and its `setTimeout` yield are - gone, and the browser now schedules exactly as `fjs t` does. Nothing - asked for the batching, no measurement motivated the constant, and it was - the origin of six rounds of review in the reverted attempt. + rather than inherited: `batchSize = 25` is gone. Nothing asked for it, no + measurement motivated the constant, and it was the origin of six rounds of + review in the reverted attempt. Deleting the *yield* along with it was the + overshoot — a page that never gives the thread back cannot paint or answer + a click — so the browser interpreter gives it back on a frame budget + instead, which is a number about the host rather than about proofs. The + traversal still schedules nothing, so `fjs t` is unchanged. **What the skeleton had to grow**, rather than what the browser had to keep: the traversal now threads a `RunOutcome` — the folded totals @@ -513,21 +524,43 @@ are shared. runner's own functions rather than against a spelling. - [x] Record every behaviour the browser file has today and the shared core will not keep, as an issue, before the sharing change merges. Two: the - `batchSize = 25` yielding, deleted deliberately so both runners schedule - identically, and the unguarded read of a module's *exported* tree, which - stays the page's own and is tracked by + `batchSize = 25` yielding — whose *constant* was the mistake and whose + *yielding* was load-bearing, see below — and the unguarded read of a + module's *exported* tree, which stays the page's own and is tracked by [hostile-proof-values](./hostile-proof-values.md). - [ ] Close each of those issues for both runners at once, so the two stay in sync rather than drifting from the day the core is shared. -- [ ] Decide where progress rendering belongs, if a suite ever grows large - enough for it to matter. Leaves resolve through microtasks, so a run - drains without a paint and the rows a page appends as results land only - become visible when it ends. The deleted `batchSize` is not the answer — - this file already records that its `setTimeout` yield had no paint - boundary where it claimed one — and the traversal is the wrong place to - look for one either way: what to paint, and when, is the page's own - concern, so a renderer that batches its DOM writes is where this lands if - anything does. +- [x] Decide where a browser run gives the thread back. **The browser + interpreter's `sandbox`, on a frame budget** — 8 ms, what a 60 Hz frame + leaves for script — not a count of proofs, and not the traversal, which + stays free of scheduling so `fjs t` is untouched. + + This was got wrong twice before it was measured, and both errors are + worth keeping. First, deleting `batchSize = 25` was read as deleting the + whole idea: the constant was indefensible — twenty-five trivial leaves + are nothing and twenty-five heavy ones are still a freeze — but the + `setTimeout` between waves was the only thing giving the page a turn. + Without it the whole suite is one task: leaves resolve through + microtasks, and a microtask drain never returns to the event loop, so + nothing paints and no click is answered until the run ends. Measured in + Chromium on this repo's own browser suite: a single **54.7 s** task, zero + rows painted, and the browser offering to kill the page. + + Second, the fix's first shape awaited the budget *before* each leaf, and + changed nothing. A leaf runs synchronously inside its handler, which is + what makes `all`'s children start one after another as each previous leaf + finishes; await anything first — even a resolved promise — and every + handler asks whether the slice is spent at the same instant, before any + leaf has run. All see room, none yields. The check has to answer without + awaiting when there is room, which is why it answers `null` rather than a + settled promise. + + After: longest task **98 ms** on the first run and **no task over 50 ms** + on the second, 3456 rows painted, wall clock 52.2 s against 52.8 s — the + yields cost 0.38 ms each and the budget asks for few of them. `all` was + not the place to put this: it must start every child before awaiting any, + so pausing between children hangs a graph whose child waits on a later + sibling, which is the deadlock the reverted attempt hit. - [ ] Prove `runBrowserProofs`'s `infrastructure-error` branch — the run's own failure, as opposed to any proof's. It is the one branch of the page with no proof, and reaching either half of it (an operation reporting through From 91eb2d2974cc93df2b465b7262aaa43b84b93c6e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:29:02 +0000 Subject: [PATCH 245/370] todo: stage 6 JSON output is rejected when unrepresentable Review finding: the normalizer's JSON output needs a representability rule. DataJS output is total; JSON output is permitted only when every leaf has a JSON spelling and no graph sharing is lost, and otherwise the value is rejected as an error rather than substituted or dropped, matching json-bigint-serialization's validation policy. Rejection proofs cover each unrepresentable leaf and the shared-node case. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 0f623c0e0..2218b7167 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -236,7 +236,15 @@ throughout. not deferred to stage 7. 6. **Compiler output** — the normalizer: data-only FunctionalScript (imports resolved and inlined) to normalized DataJS or JSON, with the subset-law - proofs above. + proofs above. DataJS output is total; JSON output is permitted only when + every leaf has a JSON spelling and no graph sharing is lost — a value + containing `undefined`, `NaN`, `±Infinity`, or a shared node is + **rejected as an error**, never silently substituted or dropped, + matching the validation policy of + [json-bigint-serialization](../fjs/djs/todo/json-bigint-serialization.md) + (`bigint` itself is representable: it serializes as its full digits). + Rejection proofs cover each unrepresentable leaf and the shared-node + case. 7. **Cleanup** — retire `fjs/js/tokenizer` when its last consumer is gone (`fjs/js/string_escape` and `fjs/js/keywords` remain as shared, JS-spec-frozen tables); the clean-break release with `**BREAKING From ff9f8f92ff908d97f7e637b144bfdb2f496da0f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:30:05 +0000 Subject: [PATCH 246/370] ci: check the packed package without a checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check Stage 2 exists for. A new `package-check` job downloads the tarball uploaded by node26, installs it as a real dependency, and type-checks every declaration the package ships. Deliberately not built through `toSteps`: that helper injects `actions/checkout`, and the missing checkout is the whole point. With no repository on the runner there is no tsconfig.json up the tree to inherit, no node_modules to resolve into, and no source file that can stand in for a declaration the tarball omits, so the job sees what a consumer sees. Four properties decide whether it can fail at all, and each is asserted: declarations are enumerated from the installed artifact rather than a written list, so a module that gains a private type module later is checked without the job being edited; skipLibCheck stays at its false default; the file list is non-empty, which is the one way the job could look healthy while checking nothing; and the compiler is the package's own pin, read out of the packed package.json, since without a checkout there is no lockfile and the registry would otherwise decide the verdict. Ran the emitted script verbatim against a real tarball rather than trusting the generator: 395 declarations, TypeScript 7.0.2 resolved from the packed pin, exit 0. Removing the 16 private.d.ts leaves 379 and still exits 0, so the exclusion in the next step is safe; appending a dangling private import to one packed declaration exits 2 with TS2307. The job is green today because nothing is excluded yet — its value appears when the negation lands, which is why the guard comes first. The proof kills five mutants: a checkout step, a dropped needs edge, a needs edge pointing at a job that does not produce the artifact, skipLibCheck true, and a dropped empty-list guard. Two existing assertions moved, both by design rather than accommodation. The job count is 13 -> 14, and jobNeeds asserted no job ordered itself; that guard fired exactly when its first consumer arrived and now pins one ordering edge, so a second stays a deliberate change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 23 ++++++++++++++++ fjs/ci/config/module.f.mjs | 2 ++ fjs/ci/module.f.mjs | 2 ++ fjs/ci/node/module.f.mjs | 6 ++++ fjs/ci/package/module.f.mjs | 55 +++++++++++++++++++++++++++++++++++++ fjs/ci/proof.f.mjs | 52 ++++++++++++++++++++++++++++++----- 6 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 fjs/ci/package/module.f.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e66c13fa..3dd87d53b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -520,6 +520,29 @@ } ] }, + "package-check": { + "runs-on": "ubuntu-26.04-arm", + "needs": [ + "node26" + ], + "steps": [ + { + "uses": "actions/download-artifact@v8.0.1", + "with": { + "name": "package-tarball" + } + }, + { + "uses": "actions/setup-node@v7.0.0", + "with": { + "node-version": "26.7.0" + } + }, + { + "run": "set -eu\nnpm init -y > /dev/null\nnpm install --no-audit --no-fund ./*.tgz\n# The compiler is the package's own pin, read out of the packed package.json:\n# with no checkout there is no lockfile, so an unpinned install would let the\n# registry change this check's verdict with no change to this repository.\nts=$(node -p \"require('./node_modules/functionalscript/package.json').devDependencies.typescript.replace(/^=/, '')\")\nnpm install --no-audit --no-fund typescript@\"$ts\"\n# Every declaration the package ships, enumerated from the installed artifact.\n# A hand-written import list cannot see a module that gains a private type\n# module later, which is the case this check exists to catch.\nfind node_modules/functionalscript \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt\n# An empty list would type-check nothing and pass, which is the one way this\n# job can look healthy while checking nothing at all.\ntest -s declarations.txt\n# skipLibCheck stays at its false default: it is what makes tsc open these\n# declarations and report a reference the tarball does not carry.\nnpx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt" + } + ] + }, "nix-flakes": { "runs-on": "ubuntu-26.04-arm", "steps": [ diff --git a/fjs/ci/config/module.f.mjs b/fjs/ci/config/module.f.mjs index dfcfa8411..8bed9ba7c 100644 --- a/fjs/ci/config/module.f.mjs +++ b/fjs/ci/config/module.f.mjs @@ -75,6 +75,8 @@ export const actions = /** @type {const} */({ 'actions/cache': 'v6.1.0', // https://github.com/marketplace/actions/upload-a-build-artifact 'actions/upload-artifact': 'v7.0.1', + // https://github.com/marketplace/actions/download-a-build-artifact + 'actions/download-artifact': 'v8.0.1', // https://github.com/marketplace/actions/setup-deno 'denoland/setup-deno': 'v2.0.5', // https://github.com/marketplace/actions/setup-bun diff --git a/fjs/ci/module.f.mjs b/fjs/ci/module.f.mjs index dec4e7ba3..cd406ad1f 100644 --- a/fjs/ci/module.f.mjs +++ b/fjs/ci/module.f.mjs @@ -25,6 +25,7 @@ import { import { rustPlatformSteps, rustWasmSteps } from './rust/module.f.mjs' import { nodeMainSteps, nodeNixJobs, nodeNixVersionSteps, nodeVersionJobs } from './node/module.f.mjs' import { nixFlakes, nixInstall } from './nix/module.f.mjs' +import { packageCheckJob, packageCheckJobId } from './package/module.f.mjs' import { bunSteps } from './bun/module.f.mjs' import { denoSteps } from './deno/module.f.mjs' @@ -55,6 +56,7 @@ const canonicalJobs = rust => ({ deno: ubuntuArm(denoSteps(functionalscript)), bun: ubuntuArm(bunSteps(functionalscript)), ...nodeVersionJobs(functionalscript), + [packageCheckJobId]: packageCheckJob, 'nix-flakes': nixFlakeJob, }) diff --git a/fjs/ci/node/module.f.mjs b/fjs/ci/node/module.f.mjs index 20ffa63eb..8783bd2bd 100644 --- a/fjs/ci/node/module.f.mjs +++ b/fjs/ci/node/module.f.mjs @@ -24,6 +24,12 @@ export const major = v => v.split('.')[0] /** @type {(version: string) => string} */ const jobId = version => `node${major(version)}` +/** + * The job that packs the tarball and uploads it. A consuming job names this in + * `needs` rather than repeating the id. + */ +export const packageJobId = jobId(node.default) + /** @type {(v: string) => Step} */ const installNode = v => uses('actions/setup-node', { 'node-version': v }) diff --git a/fjs/ci/package/module.f.mjs b/fjs/ci/package/module.f.mjs new file mode 100644 index 000000000..da00dae2d --- /dev/null +++ b/fjs/ci/package/module.f.mjs @@ -0,0 +1,55 @@ +/** + * The packed-package check: a job that consumes the `npm pack` artifact the + * way an outside consumer would. + * + * @import { Job } from '../common/types.ts' + */ + +import { images, node } from '../config/module.f.mjs' +import { uses } from '../common/module.f.mjs' +import { packageArtifact, packageJobId } from '../node/module.f.mjs' + +export const packageCheckJobId = /** @type {const} */ ('package-check') + +// Deliberately not built through `toSteps`: that helper injects +// `actions/checkout`, and the missing checkout is this job's whole point. With +// no repository on the runner there is no `tsconfig.json` up the tree to +// inherit, no `node_modules` to resolve into, and no source file that could +// stand in for a declaration the tarball omits — so the job can only see what +// a real consumer sees. +const script = /** @type {const} */ (`set -eu +npm init -y > /dev/null +npm install --no-audit --no-fund ./*.tgz +# The compiler is the package's own pin, read out of the packed package.json: +# with no checkout there is no lockfile, so an unpinned install would let the +# registry change this check's verdict with no change to this repository. +ts=$(node -p "require('./node_modules/functionalscript/package.json').devDependencies.typescript.replace(/^=/, '')") +npm install --no-audit --no-fund typescript@"$ts" +# Every declaration the package ships, enumerated from the installed artifact. +# A hand-written import list cannot see a module that gains a private type +# module later, which is the case this check exists to catch. +find node_modules/functionalscript \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt +# An empty list would type-check nothing and pass, which is the one way this +# job can look healthy while checking nothing at all. +test -s declarations.txt +# skipLibCheck stays at its false default: it is what makes tsc open these +# declarations and report a reference the tarball does not carry. +npx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt`) + +/** + * Downloads the packed tarball, installs it as a real dependency, and + * type-checks every declaration it ships. + * + * @type {Job} + */ +export const packageCheckJob = { + 'runs-on': images.ubuntu.arm, + // Without this the two jobs race and the download fails before the check + // has run — red for a reason unrelated to what it tests. + needs: [packageJobId], + steps: [ + uses('actions/download-artifact', { name: packageArtifact }), + uses('actions/setup-node', { 'node-version': node.default }), + { run: script }, + ], +} diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index 3209943e3..d13544706 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -7,7 +7,8 @@ import { exitCode } from '../effects/node/module.f.mjs' import { ci, main } from './module.f.mjs' import { actions, functionalscript, node } from './config/module.f.mjs' -import { major, nodeNixJobs, packageArtifact } from './node/module.f.mjs' +import { major, nodeNixJobs, packageArtifact, packageJobId } from './node/module.f.mjs' +import { packageCheckJobId } from './package/module.f.mjs' import { utf8, utf8ToString } from '../text/module.f.mjs' import { empty as emptyVec } from '../types/bit_vec/module.f.mjs' import { test, ubuntu, parseGitHubAction } from './common/module.f.mjs' @@ -85,7 +86,7 @@ const runDefault = packageJson => { export const proof = { matrixShape: () => { const gha = run(true) - assertEq(Object.keys(gha.jobs).length, 13, 'expected 13 CI jobs') + assertEq(Object.keys(gha.jobs).length, 14, 'expected 14 CI jobs') assertEq(gha.permissions.contents, 'read', 'expected read-only contents permission') assertEq(Object.keys(gha.permissions).length, 1, 'expected least-privilege workflow permissions') assert(hasRunInJob('ubuntu-intel', 'cargo test --target i686-unknown-linux-gnu')(gha), 'expected Ubuntu Intel i686 check') @@ -254,6 +255,41 @@ export const proof = { 1, 'expected exactly one job to upload the package') }, + packageCheck: () => { + const gha = run(false) + const job = gha.jobs[packageCheckJobId] + assert(job !== undefined, 'expected the packed-package check job') + // The defining property. With a checkout there is a tsconfig.json up + // the tree, a node_modules to resolve into, and source files that can + // stand in for a declaration the tarball omits — the check would then + // pass on the repository rather than on the package. + assert( + !job.steps.some(step => step.uses?.startsWith('actions/checkout@') === true), + 'the package check must not check out the repository') + // Ordered after the producer, and the producer is the job that + // actually uploads — asserting the edge alone would not catch it + // pointing at a job that never produces the artifact. + assertEq(job.needs?.[0], packageJobId) + assert( + gha.jobs[packageJobId]?.steps.some( + step => step.uses?.startsWith('actions/upload-artifact@') === true) === true, + 'expected the needed job to be the one that uploads') + // Downloaded by the name the producer exports, not a second literal. + const download = job.steps.find( + step => step.uses?.startsWith('actions/download-artifact@') === true) + assertEq(download?.with?.name, packageArtifact) + // The properties that decide whether this job can fail at all. Each is + // load-bearing: `skipLibCheck` true silently stops the checking, + // enumerating from the installed package is what sees a module that + // gains a private type module later, and an empty file list would + // type-check nothing and pass. + assert(hasRunInJob(packageCheckJobId, '--skipLibCheck false')(gha), 'expected skipLibCheck left false') + assert(hasRunInJob(packageCheckJobId, 'find node_modules/functionalscript')(gha), 'expected declarations enumerated from the artifact') + assert(hasRunInJob(packageCheckJobId, 'test -s declarations.txt')(gha), 'expected a guard against an empty file list') + // The compiler is the package's own pin: with no checkout there is no + // lockfile, so an unpinned install lets the registry change the verdict. + assert(hasRunInJob(packageCheckJobId, 'devDependencies.typescript')(gha), 'expected the compiler pinned from the packed package.json') + }, jobNeeds: () => { const steps = /** @type {const} */ ([{ run: 'echo hi' }]) /** @type {(jobs: Unknown) => Unknown} */ @@ -283,10 +319,12 @@ export const proof = { assertEq(parseGitHubAction(action({ check: { 'runs-on': 'ubuntu-latest', needs: 'pack', steps }, }))[0], 'error') - // Dormant until something orders itself: the first consumer is the - // packed-artifact check in `fjs/ci/todo/f-mjs-package-support.md`. - assert( - definedValues(run(false).jobs).every(job => job.needs === undefined), - 'unexpected job ordering in the generated workflow') + // Exactly one job orders itself: the packed-package check, which + // cannot start before the artifact it consumes exists. Pinning the + // count keeps a second ordering edge a deliberate change rather than + // something that appears unnoticed — ordering is where a workflow + // starts to have a shape that has to be reasoned about. + const orderedJobs = definedValues(run(false).jobs).filter(job => job.needs !== undefined) + assertEq(orderedJobs.length, 1, 'unexpected job ordering in the generated workflow') }, } From 37dc5bedb9d20df3aeb1d341be2e8cb6f36c38a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:30:32 +0000 Subject: [PATCH 247/370] todo: the node26 issue outlived the gates it was filed beside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught the issue describing a tree that no longer exists. It listed "both halves of the `@module` placement rule" among the repository-only gates `fjs ci` ships to consumers, and the revert in 1932280 removed them — so it recorded as shipped a pair of steps the same pull request had already taken out, and told whoever picks it up that consumers fail on a rule this branch deliberately leaves unenforced. The inventory is now the two that are actually generated: `npm run ci-update` with its drift check, and the file-scope `@typedef` prohibition. The `@module` attempt is kept as history rather than deleted — it is why the issue exists — but stated as reverted, with the reason and a pointer to root `AGENTS.md` §6, which now rules the approach out. Nothing else in the file assumed the gates: the two options, the question of whether any outside project runs `fjs ci`, and the README disagreement all stand as written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- fjs/ci/todo/node26-job-is-this-repo-only.md | 22 +++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/fjs/ci/todo/node26-job-is-this-repo-only.md b/fjs/ci/todo/node26-job-is-this-repo-only.md index 20ed98132..381035646 100644 --- a/fjs/ci/todo/node26-job-is-this-repo-only.md +++ b/fjs/ci/todo/node26-job-is-this-repo-only.md @@ -15,18 +15,20 @@ theirs: - `npm run ci-update`, then `git add -A && git diff --cached --exit-code` — regenerate-and-check-drift, against a script a consumer's `package.json` very likely does not define, so the step fails outright; -- the file-scope JSDoc `@typedef` prohibition (root `AGENTS.md`); -- both halves of the `@module` placement rule (`fjs/AGENTS.md` §2), added by - the change that filed this issue. +- the file-scope JSDoc `@typedef` prohibition (root `AGENTS.md`). -The last three encode *this repository's* conventions. A consumer who writes a -file-scope `@typedef`, or puts `@module` on a `types.ts`, has broken no rule of -their own, and their build fails telling them so. +Both encode *this repository's* conventions, and the second is the clearer +case: a consumer who writes a file-scope `@typedef` has broken no rule of their +own, and their build fails telling them so. -This is not a defect the `@module` guards introduced — `npm run ci-update` has -the same shape and predates them. What they did was make the pattern worth -naming: each convention added to `node26` widens the gap between what `fjs ci` -claims to generate and what it does. +This issue was filed alongside a pair of `@module` placement gates that would +have been a third. They were reverted before landing — a text pattern cannot +tell a JSDoc tag from the same characters in a string, and +[root `AGENTS.md` §6](../../../AGENTS.md#6-external-tools) now rules the +approach out — so the tree today carries only the two above. What the attempt +did was make the pattern worth naming: every convention `node26` acquires +widens the gap between what `fjs ci` claims to generate and what it does, and +§6 makes that gap harder to widen without noticing. ### Proposal From 697537614205e45ef81661ec2f68b2cb7da2b40e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:30:57 +0000 Subject: [PATCH 248/370] todo: canonical DataJS layout is one line; readable output is the default Resolves the deferred canonical-layout decision: normalized form is the fully minified one-line spelling, leaving normalization zero layout freedom for byte-determinism; tooling defaults to a human-readable layout, which is one of the many valid non-normalized spellings. The media type remains the spec stage's one deferred detail. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 2218b7167..c3d33cd05 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -164,8 +164,12 @@ round-trip guarantee forbids; shortest round-trip number spelling; bigints as full digits + `n`; fixed string escaping). Normalization is not a blocker for the format spec. The serializer cannot delegate numbers to `JSON.stringify` (it loses `-0` and non-finite values); DataJS owns its -number writer. Whether the canonical layout is fully minified or one statement -per line is decided in the spec stage. +number writer. The canonical layout is **one line** — fully minified, with +whitespace only where two word-tokens meet — so normalization has zero +layout freedom, which is what byte-determinism (and any future content +addressing) needs. Tooling *defaults* to a human-readable layout (one +statement per line, indented containers), which is simply one of the many +valid non-normalized spellings; normalized output is requested explicitly. **Extensions.** Recognized: `.data.js`, `.data.mjs`, `.d.js`, `.d.mjs`. Emitted and canonical: `.data.js` (`.data.mjs` where unambiguous ESM @@ -202,7 +206,8 @@ throughout. 1. **Spec** — `spec/datajs/`: format spec (grammar as BNF text, data model, rationale) plus the normalization section, and the conformance test vectors (accept, reject, round-trip) that every later stage runs against. - Decides the two deferred details: canonical layout, media type. + Decides the one remaining deferred detail: the media type. (The + canonical layout is decided: one line — see **Serialization** above.) 2. **Dead code** — delete `fjs/fsc/bnf.f.mjs` and `fjs/fsc/json.f.mjs`, or convert the salvageable parts into proof-covered `fjs/bnf/**` examples. Resolves [orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md). From aa73acc914f3819d20151570917badfba20f8485 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:34:33 +0000 Subject: [PATCH 249/370] todo: `ci-update` is a documented contract, not a repository-only gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review checked `fjs/ci/README.md`, which I had not read past its opening. It names `cov` and `ci-update` as the commands a consuming `package.json` must provide, shows the typical definitions, and explains that a project chains its own generators into `ci-update` so the drift check covers them for free. This repository's `node ./fjs/module.mjs ci && …` spelling is explained there too: it avoids depending on the package bin before the package is installed. So the step is an extension point, and listing it as this repository's private gate was wrong. That correction cuts the other way from how I argued it. I claimed on the pull request that `node26` is already unusable for consumers, so a convention gate added to it changes nothing for them. The opposite holds: a project that follows the documented setup has a working `node26`, and the one step outside the contract — the file-scope `@typedef` prohibition — is then the only thing standing between them and a green build. The reviewer who called the reverted `@module` gates a real consumer regression was right, and my rebuttal rested on not having read the contract. The issue is rescoped to that one step, and retitled, since "ships this repository's own gates" overstated a job that is mostly portable by design. The narrowness is the finding rather than a softening of it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- fjs/ci/todo/node26-job-is-this-repo-only.md | 47 ++++++++++++--------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/fjs/ci/todo/node26-job-is-this-repo-only.md b/fjs/ci/todo/node26-job-is-this-repo-only.md index 381035646..46fd33852 100644 --- a/fjs/ci/todo/node26-job-is-this-repo-only.md +++ b/fjs/ci/todo/node26-job-is-this-repo-only.md @@ -1,4 +1,4 @@ -## node26-job-is-this-repo-only. `fjs ci` ships this repository's own gates +## node26-job-is-this-repo-only. `fjs ci` ships one convention consumers never agreed to **Priority:** P3 **Status:** open @@ -9,34 +9,41 @@ ([`fjs/README.md`](../../README.md)), and `ci(setup)` lets a caller vary only `nodeExtra`, which reaches the per-OS platform jobs. The canonical Node jobs come from `nodeVersionJobs` unconditionally, so every consumer's generated -`ci.yml` also gets the `node26` job — and that job is this repository's, not -theirs: +`ci.yml` also gets the `node26` job. -- `npm run ci-update`, then `git add -A && git diff --cached --exit-code` — - regenerate-and-check-drift, against a script a consumer's `package.json` - very likely does not define, so the step fails outright; -- the file-scope JSDoc `@typedef` prohibition (root `AGENTS.md`). +Most of that job is a documented contract and works as intended. +[`../README.md`](../README.md) states which commands a consuming +`package.json` must provide — `cov` and `ci-update` — shows the typical +definitions, and explains that a project chains its own generators into +`ci-update` so the drift check covers them for free. This repository's own +`ci-update` spells itself `node ./fjs/module.mjs ci && …` only to avoid +depending on the package bin before the package is installed, which that README +says outright. So `npm run ci-update` and its drift check are an extension +point, not a private gate. -Both encode *this repository's* conventions, and the second is the clearer -case: a consumer who writes a file-scope `@typedef` has broken no rule of their -own, and their build fails telling them so. +**One step is not covered by that contract: the file-scope JSDoc `@typedef` +prohibition.** It comes from root `AGENTS.md`, nothing asks a consumer to adopt +it, and no `Setup` field turns it off. A project that follows the documented +setup exactly — defines both scripts, writes ordinary JSDoc — gets a red +`node26` for breaking a rule that is not theirs, and the failure names a +convention they have never read. -This issue was filed alongside a pair of `@module` placement gates that would -have been a third. They were reverted before landing — a text pattern cannot -tell a JSDoc tag from the same characters in a string, and -[root `AGENTS.md` §6](../../../AGENTS.md#6-external-tools) now rules the -approach out — so the tree today carries only the two above. What the attempt -did was make the pattern worth naming: every convention `node26` acquires -widens the gap between what `fjs ci` claims to generate and what it does, and -§6 makes that gap harder to widen without noticing. +That narrowness is the finding. Because the rest of the job does work for a +consumer who follows the documentation, a convention gate added to it is not +lost in an already-broken job: it is the one thing standing between them and a +green build. A pair of `@module` gates was very nearly added here for that +reason and reverted first — [root `AGENTS.md` +§6](../../../AGENTS.md#6-external-tools) now rules that approach out — but §6 +governs *how* such a check is built, not whether `node26` is where it belongs. ### Proposal No design agreed; the choice is what `fjs ci` is *for*. - **Split the job.** `nodeVersionJobs` yields the portable per-version jobs; - this repository's gates move to a `nodeExtra`-style hook it passes itself. - A consumer gets Node 22/24/26 running their tests and nothing else. + this repository's convention gates move to a `nodeExtra`-style hook it passes + itself. A consumer keeps the documented `cov`/`ci-update` contract and gets + none of our conventions. - **Or narrow the claim.** Keep the job as it is and say in `fjs/README.md` and [`../README.md`](../README.md) that `fjs ci` generates *this* repository's workflow, and that other projects should use `fjs run ` — From fd68a5542a82068d9cc5749e4491f8d3940193ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:35:36 +0000 Subject: [PATCH 250/370] todo: object key order is JS own-property order, integer keys included Review finding: "position from the first occurrence" alone lets a non-JS implementation preserve {"2":0,"1":0} as written, while every JS engine observably enumerates "1" before "2" - array-index keys come first in ascending numeric order, then other keys in first-occurrence order. The data model now states JS own-property ordering explicitly and normalized output emits keys in that observable order. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index c3d33cd05..5794fc62d 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -92,8 +92,15 @@ package publishes; the spec does not need it. **Data model.** A DAG of values. Leaves are JSON's primitives plus `bigint`, `undefined`, `NaN`, `Infinity`, `-Infinity`, `-0`. Number round-trips satisfy -`Object.is`. Object entries follow JS duplicate-key semantics exactly: value -from the last occurrence, position from the first. Sharing is semantic — two +`Object.is`. Object entries follow JS object semantics exactly, and the spec +restates both halves rather than citing ECMA-262. Duplicate keys: value from +the last occurrence, position from the first. Observable key order is JS's +own-property ordering: keys that are array indices (canonical numeric +strings, `0` ≤ n < 2^32−1) come first in ascending numeric order, then all +other keys in first-occurrence order — `{"2":0,"1":0}` observably orders +`"1"` before `"2"` in every JS engine, and a non-JS implementation must +reorder the same way. Normalized output emits keys in that observable +order. Sharing is semantic — two references to one `const` denote the same node, and references may only point at *earlier* consts, so a document is acyclic by construction and parseable in one pass. The reference parser returns live JS values and does not freeze them From fe7ee2d8762c7dc730350dda52c5f4b3bfb9a186 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:36:12 +0000 Subject: [PATCH 251/370] todo: P5, and a filename that matches what is left of the finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Priority drops P3 to P5. Reviewers — human and agent — catch this class of thing, and no project outside this repository is known to run `fjs ci` at all, which the issue already asks whoever picks it up to check first. It is worth the day a consumer turns up and not much before. The file is renamed with it. `node26-job-is-this-repo-only` asserted the claim the previous commit retracted: most of that job is the documented `cov`/`ci-update` contract and works for a conforming consumer. Only the file-scope `@typedef` gate sits outside it, so the name says that instead. Free to do now — the file is new in this branch and nothing links to it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- ...o-only.md => node26-typedef-gate-reaches-consumers.md} | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) rename fjs/ci/todo/{node26-job-is-this-repo-only.md => node26-typedef-gate-reaches-consumers.md} (92%) diff --git a/fjs/ci/todo/node26-job-is-this-repo-only.md b/fjs/ci/todo/node26-typedef-gate-reaches-consumers.md similarity index 92% rename from fjs/ci/todo/node26-job-is-this-repo-only.md rename to fjs/ci/todo/node26-typedef-gate-reaches-consumers.md index 46fd33852..f51d50993 100644 --- a/fjs/ci/todo/node26-job-is-this-repo-only.md +++ b/fjs/ci/todo/node26-typedef-gate-reaches-consumers.md @@ -1,6 +1,6 @@ -## node26-job-is-this-repo-only. `fjs ci` ships one convention consumers never agreed to +## node26-typedef-gate-reaches-consumers. `fjs ci` ships one convention consumers never agreed to -**Priority:** P3 +**Priority:** P5 **Status:** open ### Problem @@ -28,6 +28,10 @@ setup exactly — defines both scripts, writes ordinary JSDoc — gets a red `node26` for breaking a rule that is not theirs, and the failure names a convention they have never read. +P5: a reviewer notices this kind of thing, and no project outside this +repository is known to run `fjs ci` at all — see the question under the +options. Raise it the day one turns up. + That narrowness is the finding. Because the rest of the job does work for a consumer who follows the documentation, a convention gate added to it is not lost in an already-broken job: it is the one thing standing between them and a From ab01d2b72bb42505af49516c282bae79c31ceaaa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:36:51 +0000 Subject: [PATCH 252/370] rtti: scope the gate's claim, and record the hostile-length case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #1766, reproduced: an array proxy over ['bad'] whose first get('length') deletes index 0 is now accepted by both readers against [or(option, number)], where they rejected it before and the data form still does. The early read is an added observable operation, so a mutating length getter fires before the presence decisions rather than after. Acceptance is unchanged for every value whose length read is side-effect-free — every DJS value and every ordinary array — and the blanket claim is corrected to say so. The case is added to hostile-accessor-hermetic-read-path.md, which already tracks accessors steering the closed check, noting that what a fix must restore is the thunk readers' agreement with the data form. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/rtti/parse/module.f.mjs | 10 ++++++++++ fjs/rtti/todo/hostile-accessor-hermetic-read-path.md | 10 ++++++++++ fjs/rtti/validate/module.f.mjs | 10 ++++++++++ 3 files changed, 30 insertions(+) diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 94294441b..e12636d02 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -354,6 +354,16 @@ const constContainerParse = // nested chain costs 2^depth; with it the arm is decided before // any recursion. `parse` gates identically, which is what keeps // the two readers reporting the same error. + // + // The read is an added observable operation, and under a hostile + // accessor that is not neutral: a `length` getter that mutates a + // declared member now fires *before* the members are read, where + // it used to fire after, so it can steer the verdict the readers + // then reach. That is the class + // `../todo/hostile-accessor-hermetic-read-path.md` tracks, and + // this gate adds one instance of it — see the bullet there. For + // every value whose `length` read is side-effect-free — every DJS + // value, and every ordinary array — acceptance is untouched. if (!fits(value, declared.length)) { return verror('unexpected value') } diff --git a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md index e92e2e37f..edf9d00f0 100644 --- a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md +++ b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md @@ -23,6 +23,16 @@ callers: check or a `rest` sees. The tuple length bound catches the simplest variant, but a `rest` kind can be steered into accepting a value whose undeclared members were never held to the rest. +- `constContainerValidate`/`constContainerParse` bound the container by + `length` **before** reading its members, which is what lets an `or` of two + arities decide an arm without recursing (`fjs/edag`'s chain nodes). A + `length` getter that mutates therefore fires before the presence decisions + rather than after: an array proxy over `['bad']` whose first + `get('length')` deletes index 0 is accepted by both readers against + `[or(option, number)]`, where the data form — reading the value its own way + — still rejects it. Measured; the two thunk readers agree with each other + throughout, so what a fix has to restore is their agreement with the data + form. - `visit` and `absenceIn` destructure the schema thunk's descriptor (`const [tag, ...operands] = rtti()`), which dispatches `Array.prototype[Symbol.iterator]` — patched, the accessor chooses the diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index 76b3ffc29..1fec81fc2 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -229,6 +229,16 @@ const constContainerValidate = // nested chain costs 2^depth; with it the arm is decided before // any recursion. `parse` gates identically, which is what keeps // the two readers reporting the same error. + // + // The read is an added observable operation, and under a hostile + // accessor that is not neutral: a `length` getter that mutates a + // declared member now fires *before* the members are read, where + // it used to fire after, so it can steer the verdict the readers + // then reach. That is the class + // `../todo/hostile-accessor-hermetic-read-path.md` tracks, and + // this gate adds one instance of it — see the bullet there. For + // every value whose `length` read is side-effect-free — every DJS + // value, and every ordinary array — acceptance is untouched. if (!fits(value, declared.length)) { return verror('unexpected value') } From 54784c0c5e3c741c0db12cdb4598052c1e971ee6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:39:41 +0000 Subject: [PATCH 253/370] todo: shorten the issue heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit todo/README.md asks for a heading that names the action or the thing, not the full context. "ships one convention consumers never agreed to" carried the diagnosis in the title; the Problem section already states it, at length and more accurately. Kept the `{slug}.` prefix — it is what 151 of the 279 issue files do, and the neighbours this branch touched are among them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- fjs/ci/todo/node26-typedef-gate-reaches-consumers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fjs/ci/todo/node26-typedef-gate-reaches-consumers.md b/fjs/ci/todo/node26-typedef-gate-reaches-consumers.md index f51d50993..31f91896c 100644 --- a/fjs/ci/todo/node26-typedef-gate-reaches-consumers.md +++ b/fjs/ci/todo/node26-typedef-gate-reaches-consumers.md @@ -1,4 +1,4 @@ -## node26-typedef-gate-reaches-consumers. `fjs ci` ships one convention consumers never agreed to +## node26-typedef-gate-reaches-consumers. `fjs ci` ships our `@typedef` rule **Priority:** P5 **Status:** open From 837eda6ab56f2b6bdcd56fb9c2bb8f6883b34062 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:40:17 +0000 Subject: [PATCH 254/370] ci: the package check works for any package, and no regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all correct. The script hard-coded node_modules/functionalscript. `fjs ci` is a public command that generates workflows for other projects, and the proof already exercises a package named other-package, so for them the check would fail on a missing directory — or worse, silently validate a `functionalscript` dependency that happens to be installed instead of the artifact just built. That is a plausible wrong answer where DESIGN.md §10 requires a refusal. The package directory is now derived from the tarball, by reading back the single dependency `npm install ./*.tgz` writes. Verified by building a second tarball named other-package and running the emitted script against it: resolves other-package, finds its declaration, exit 0. Against this repository's own tarball: 395 declarations, TypeScript 7.0.2, exit 0. A package with no compiler pin exits non-zero rather than installing a floating compiler. The regex `/^=/` is gone, and not by rewriting it: npm accepts the `=7.0.2` range verbatim, so the stripping was never needed. fjs/AGENTS.md forbids regular expressions; the fix removes the reason for one rather than the syntax. Adding a job changes the workflow `fjs ci` emits for every caller, which is a behavior change for users of the package rather than an internal refactor, so this adds the changelog entry it needs. The proof now pins the derivation and asserts the hard-coded name is absent, so the regression cannot come back quietly. Changelog: changelog/unreleased/1767.md Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 2 +- changelog/unreleased/1767.md | 4 ++++ fjs/ci/package/module.f.mjs | 15 +++++++++++---- fjs/ci/proof.f.mjs | 11 +++++++++-- 4 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 changelog/unreleased/1767.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dd87d53b..9f78357cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -539,7 +539,7 @@ } }, { - "run": "set -eu\nnpm init -y > /dev/null\nnpm install --no-audit --no-fund ./*.tgz\n# The compiler is the package's own pin, read out of the packed package.json:\n# with no checkout there is no lockfile, so an unpinned install would let the\n# registry change this check's verdict with no change to this repository.\nts=$(node -p \"require('./node_modules/functionalscript/package.json').devDependencies.typescript.replace(/^=/, '')\")\nnpm install --no-audit --no-fund typescript@\"$ts\"\n# Every declaration the package ships, enumerated from the installed artifact.\n# A hand-written import list cannot see a module that gains a private type\n# module later, which is the case this check exists to catch.\nfind node_modules/functionalscript \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt\n# An empty list would type-check nothing and pass, which is the one way this\n# job can look healthy while checking nothing at all.\ntest -s declarations.txt\n# skipLibCheck stays at its false default: it is what makes tsc open these\n# declarations and report a reference the tarball does not carry.\nnpx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt" + "run": "set -eu\nnpm init -y > /dev/null\nnpm install --no-audit --no-fund ./*.tgz\n# The artifact installs under its own package name, which is not necessarily\n# this repository's: `fjs ci` generates workflows for other projects too. A\n# hard-coded name would fail for them, or worse, silently check a dependency\n# that happens to share the name instead of the artifact just built.\npkg=$(node -p \"Object.keys(require('./package.json').dependencies)[0]\")\n# The compiler is the package's own pin, read out of the packed package.json:\n# with no checkout there is no lockfile, so an unpinned install would let the\n# registry change this check's verdict with no change to the repository.\nts=$(PKG=\"$pkg\" node -p \"require('./node_modules/' + process.env.PKG + '/package.json').devDependencies?.typescript ?? ''\")\n# Refuse rather than fall back to a floating compiler.\ntest -n \"$ts\"\nnpm install --no-audit --no-fund \"typescript@$ts\"\n# Every declaration the package ships, enumerated from the installed artifact.\n# A hand-written import list cannot see a module that gains a private type\n# module later, which is the case this check exists to catch.\nfind \"node_modules/$pkg\" \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt\n# An empty list would type-check nothing and pass, which is the one way this\n# job can look healthy while checking nothing at all.\ntest -s declarations.txt\n# skipLibCheck stays at its false default: it is what makes tsc open these\n# declarations and report a reference the tarball does not carry.\nnpx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt" } ] }, diff --git a/changelog/unreleased/1767.md b/changelog/unreleased/1767.md new file mode 100644 index 000000000..6aa224027 --- /dev/null +++ b/changelog/unreleased/1767.md @@ -0,0 +1,4 @@ +- `fjs ci` now generates a `package-check` job: it downloads the packed tarball + uploaded by the Node job, installs it as a dependency outside any checkout, + and type-checks every declaration the package ships with the compiler version + the package itself pins. diff --git a/fjs/ci/package/module.f.mjs b/fjs/ci/package/module.f.mjs index da00dae2d..3d7d40841 100644 --- a/fjs/ci/package/module.f.mjs +++ b/fjs/ci/package/module.f.mjs @@ -20,15 +20,22 @@ export const packageCheckJobId = /** @type {const} */ ('package-check') const script = /** @type {const} */ (`set -eu npm init -y > /dev/null npm install --no-audit --no-fund ./*.tgz +# The artifact installs under its own package name, which is not necessarily +# this repository's: \`fjs ci\` generates workflows for other projects too. A +# hard-coded name would fail for them, or worse, silently check a dependency +# that happens to share the name instead of the artifact just built. +pkg=$(node -p "Object.keys(require('./package.json').dependencies)[0]") # The compiler is the package's own pin, read out of the packed package.json: # with no checkout there is no lockfile, so an unpinned install would let the -# registry change this check's verdict with no change to this repository. -ts=$(node -p "require('./node_modules/functionalscript/package.json').devDependencies.typescript.replace(/^=/, '')") -npm install --no-audit --no-fund typescript@"$ts" +# registry change this check's verdict with no change to the repository. +ts=$(PKG="$pkg" node -p "require('./node_modules/' + process.env.PKG + '/package.json').devDependencies?.typescript ?? ''") +# Refuse rather than fall back to a floating compiler. +test -n "$ts" +npm install --no-audit --no-fund "typescript@$ts" # Every declaration the package ships, enumerated from the installed artifact. # A hand-written import list cannot see a module that gains a private type # module later, which is the case this check exists to catch. -find node_modules/functionalscript \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt +find "node_modules/$pkg" \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt # An empty list would type-check nothing and pass, which is the one way this # job can look healthy while checking nothing at all. test -s declarations.txt diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index d13544706..691d8a4a4 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -284,11 +284,18 @@ export const proof = { // gains a private type module later, and an empty file list would // type-check nothing and pass. assert(hasRunInJob(packageCheckJobId, '--skipLibCheck false')(gha), 'expected skipLibCheck left false') - assert(hasRunInJob(packageCheckJobId, 'find node_modules/functionalscript')(gha), 'expected declarations enumerated from the artifact') + assert(hasRunInJob(packageCheckJobId, 'find "node_modules/$pkg"')(gha), 'expected declarations enumerated from the artifact') assert(hasRunInJob(packageCheckJobId, 'test -s declarations.txt')(gha), 'expected a guard against an empty file list') // The compiler is the package's own pin: with no checkout there is no // lockfile, so an unpinned install lets the registry change the verdict. - assert(hasRunInJob(packageCheckJobId, 'devDependencies.typescript')(gha), 'expected the compiler pinned from the packed package.json') + assert(hasRunInJob(packageCheckJobId, 'devDependencies?.typescript')(gha), 'expected the compiler pinned from the packed package.json') + // `fjs ci` generates workflows for other projects, so the artifact's + // package name is whatever that project publishes. A hard-coded name + // would fail for them — or worse, silently check a dependency that + // happens to share the name instead of the artifact just built, which + // is a plausible wrong answer rather than a refusal. + assert(hasRunInJob(packageCheckJobId, "Object.keys(require('./package.json').dependencies)[0]")(gha), 'expected the package directory derived from the artifact') + assert(!hasRunInJob(packageCheckJobId, 'node_modules/functionalscript')(gha), 'the package check must not hard-code this repository\'s package name') }, jobNeeds: () => { const steps = /** @type {const} */ ([{ run: 'echo hi' }]) From 763ee3b93fd807367a030f195d087fb44c4c05d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:40:37 +0000 Subject: [PATCH 255/370] emergent_testing: a run starts after its promise is published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A leaf executes synchronously inside its handler — the property that staggers the traversal's siblings — so the first slice of proofs ran while `runBrowserProofs` was still building what it returns, before `startBrowserTests` had published it as `fjsBrowserTestReport`. A proof that reads the run it belongs to saw the previous run's promise, or nothing at all. Everything that runs user code now waits behind one microtask, which is enough: the publication happens in the same synchronous block as the call. Enumerating an export waits there too, being user code as well. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/emergent_testing/browser.mjs | 98 +++++++++++++++----------- fjs/emergent_testing/browser/proof.mjs | 14 ++++ 2 files changed, 71 insertions(+), 41 deletions(-) diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index 3c111603f..fabdc71a5 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -175,48 +175,64 @@ export const runBrowserProofs = (modules, result = () => undefined) => { // The result stays in the report the run resolves with. } } - // Reading a module's exported tree runs user code, and the shared traversal - // deliberately does not guard that one: there is no leaf to attribute it - // to, so `fjs t` panics and the page does this instead. A module that - // cannot be enumerated is one failed module, never a run that ends without - // a report. See `todo/hostile-proof-values.md`. - // - // The export is read **once**, here, and the leaves go on to `runEntries`: - // enumerating is not idempotent, so a preliminary read that only checked - // whether the tree can be enumerated would run every getter in it a second - // time — and a getter that succeeds once and throws next would escape as a - // synchronous throw, leaving the page in `running` with no report at all. - /** @type {readonly (readonly ['ok', string, readonly _TestAndPath[]] | readonly ['failed', _BrowserTestResult])[]} */ - const prepared = modules.map(([module, proof]) => { - try { - return /** @type {const} */ (['ok', module, collectTests([], false, proof)]) - } catch (error) { - const [message, stack] = errorDetails(error) - return /** @type {const} */ (['failed', moduleFailure(module, 0, message, stack)]) + /** + * Everything that runs user code, held until the caller has this run's + * promise. + * + * A leaf executes synchronously inside its handler — that is what staggers + * the traversal's siblings — so without this the first slice of proofs + * would run while `runBrowserProofs` was still building what it returns, + * before `startBrowserTests` had published the promise as + * `fjsBrowserTestReport`. A proof that reads the run it belongs to would + * see the previous run's, or nothing. Enumerating an export is user code + * too, so it waits here as well. + * + * @type {() => Promise>} + */ + const started = () => { + // Reading a module's exported tree runs user code, and the shared traversal + // deliberately does not guard that one: there is no leaf to attribute it + // to, so `fjs t` panics and the page does this instead. A module that + // cannot be enumerated is one failed module, never a run that ends without + // a report. See `todo/hostile-proof-values.md`. + // + // The export is read **once**, here, and the leaves go on to `runEntries`: + // enumerating is not idempotent, so a preliminary read that only checked + // whether the tree can be enumerated would run every getter in it a second + // time — and a getter that succeeds once and throws next would escape as a + // synchronous throw, leaving the page in `running` with no report at all. + /** @type {readonly (readonly ['ok', string, readonly _TestAndPath[]] | readonly ['failed', _BrowserTestResult])[]} */ + const prepared = modules.map(([module, proof]) => { + try { + return /** @type {const} */ (['ok', module, collectTests([], false, proof)]) + } catch (error) { + const [message, stack] = errorDetails(error) + return /** @type {const} */ (['failed', moduleFailure(module, 0, message, stack)]) + } + }) + // The page's modules are a *list*, and nothing stops it naming the same + // module twice: two entries with one label are two runs, in the order they + // were passed, so they are run as a list rather than folded into a map + // keyed by name. + /** @type {(e: (typeof prepared)[number]) => Effect} */ + const runOne = e => e[0] === 'ok' + ? mapStep(runEntries(browserReporter)(e[1], e[2]), o => o.results) + // A module failure has no leaf to be reported by, so it is handed to + // the same `report` operation directly: the page renders it as it + // lands, in the position the module was passed in, exactly like a leaf. + : mapStep(report(e[1]), r => /** @type {readonly _BrowserTestResult[]} */ ([r])) + const all = mapStep(allOk(...prepared.map(runOne)), lists => lists.flat()) + /** @type {ToAsyncOperationMap<_BrowserReport>} */ + const page = { + // The page's end of the `report` operation: render as it lands, and + // answer the record back so the traversal can keep it in order. + report: async r => { + announce(r) + return ok(r) + }, } - }) - // The page's modules are a *list*, and nothing stops it naming the same - // module twice: two entries with one label are two runs, in the order they - // were passed, so they are run as a list rather than folded into a map - // keyed by name. - /** @type {(e: (typeof prepared)[number]) => Effect} */ - const runOne = e => e[0] === 'ok' - ? mapStep(runEntries(browserReporter)(e[1], e[2]), o => o.results) - // A module failure has no leaf to be reported by, so it is handed to - // the same `report` operation directly: the page renders it as it - // lands, in the position the module was passed in, exactly like a leaf. - : mapStep(report(e[1]), r => /** @type {readonly _BrowserTestResult[]} */ ([r])) - const all = mapStep(allOk(...prepared.map(runOne)), lists => lists.flat()) - /** @type {ToAsyncOperationMap<_BrowserReport>} */ - const page = { - // The page's end of the `report` operation: render as it lands, and - // answer the record back so the traversal can keep it in order. - report: async r => { - announce(r) - return ok(r) - }, + return browserRun(page)(all) } - const run = browserRun(page) /** * The run failed as a *runner*, not as a proof. Reporting it as the run's * own failure keeps the page out of `running` forever, which is the one @@ -237,7 +253,7 @@ export const runBrowserProofs = (modules, result = () => undefined) => { // not implement arrives as a rejected promise rather than an `error`. // Neither may escape: an unhandled rejection is a page stuck in `running` // with no report and no completion event. - return run(all).then(outcome => { + return Promise.resolve().then(started).then(outcome => { if (outcome[0] === 'error') { return infrastructureError(outcome[1]) } diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 5938751e6..b749150b5 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -364,6 +364,20 @@ export const proof = { report.results.map(r => r.path), ['.a', '.a().b', '.a().b().c', '.a().b().c().d']) }, + // A leaf runs synchronously inside its handler, so a run that started while + // its own promise was still being built would execute proofs before the + // page had published it. A proof that asks for the run it belongs to gets + // this run's promise, never the last one's. + aProofSeesItsOwnRunPublished: async () => { + const p = page() + /** @type {unknown} */ + let seen = 'never ran' + const report = await startBrowserTests(p.root, + [['m', { t: () => { seen = p.view.fjsBrowserTestReport } }]]) + assertEq(report.totals.passed, 1) + assertEq(seen, p.view.fjsBrowserTestReport) + assert(seen instanceof Promise) + }, exportedTreeIsReadOnce: async () => { // The export is enumerated exactly once. A getter that succeeds on the // first read and throws on the next is not a module failure here — but From 3015f9d232cb9795f4df9011f743f2c523d78d78 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:41:29 +0000 Subject: [PATCH 256/370] todo: post-order const emission; JSON output rejects bigint Two review findings: "first-emission order" was underdetermined for nested shared nodes, so normalization now emits consts in post-order of one depth-first traversal (arrays in element order, objects in observable key order, shared nodes descended on first encounter), which also makes declaration-before-use automatic; and the normalizer's ordinary JSON output rejects bigint alongside the other unrepresentable leaves, since emitting 1n as the JSON text 1 silently changes the value's type for the standard reader - extended-codec output stays an explicit, labeled caller choice. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 5794fc62d..1df813128 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -162,9 +162,16 @@ key ::= string | '[' '"__proto__"' ']' **Serialization.** Any conforming serializer may emit any valid document; a separate *normalized form* section defines one byte-deterministic canonical -serializer (const names `_0`, `_1`, … in first-emission order; a const emitted +serializer (a const emitted iff its value is an object or array referenced more than once **by reference -identity** — primitives are always emitted inline and never hoisted, since +identity**; consts are emitted in **post-order of one depth-first traversal** +of the root value — arrays in element order, objects in observable key +order, each shared node descended into only on first encounter — with names +`_0`, `_1`, … assigned in emission order, so a shared node's dependencies +are always declared before it and "who is `_0`" has exactly one answer: +for `root = [parent, parent, child]` with `child` inside `parent`, `child` +finishes first and is `_0`, `parent` is `_1`; primitives are always emitted +inline and never hoisted, since primitive sharing is unobservable and a value-equality ref counter would face the `0`/`-0` and `NaN` merging ambiguity that the `Object.is` round-trip guarantee forbids; shortest round-trip number @@ -250,11 +257,15 @@ throughout. resolved and inlined) to normalized DataJS or JSON, with the subset-law proofs above. DataJS output is total; JSON output is permitted only when every leaf has a JSON spelling and no graph sharing is lost — a value - containing `undefined`, `NaN`, `±Infinity`, or a shared node is - **rejected as an error**, never silently substituted or dropped, + containing `undefined`, `NaN`, `±Infinity`, `bigint`, or a shared node + is **rejected as an error**, never silently substituted or dropped, matching the validation policy of - [json-bigint-serialization](../fjs/djs/todo/json-bigint-serialization.md) - (`bigint` itself is representable: it serializes as its full digits). + [json-bigint-serialization](../fjs/djs/todo/json-bigint-serialization.md). + `bigint` is rejected even though its digits are spellable in JSON: the + text `1` read back by the standard `.json` reader is the *number* `1`, + so emitting `1n` as `1` would silently change the value's type — the + extended codec's bigint output remains available only as a caller's + explicit, so-labeled choice, never the normalizer's `.json` default. Rejection proofs cover each unrepresentable leaf and the shared-node case. 7. **Cleanup** — retire `fjs/js/tokenizer` when its last consumer is gone From fa04c499cf76e488b9ddcd3cd053197557a6a596 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:43:56 +0000 Subject: [PATCH 257/370] todo: delete the `@module` in `types.ts` issue, resolved by #1756 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues asked the same question. #1756 answered it — strip the tag, `fjs/AGENTS.md` §2 was right — and applied that to 102 files, then deleted `fjs/todo/module-tag-on-types-ts.md` and missed this one. Every premise in it is now false. "90 of the 94 `types.ts` files carry it anyway" is 0 of 98. Its three tasks are done: the convention was decided, applied to every `types.ts`, and `fjs/web/types.ts` — named as the minority case — matches. Nothing cites it. Nothing to move before deleting: the decision it asked for lives in `fjs/AGENTS.md` §2, which never changed, and the CI-enforcement question it did not ask about is settled the other way by §6. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- todo/types-ts-module-tag.md | 41 ------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 todo/types-ts-module-tag.md diff --git a/todo/types-ts-module-tag.md b/todo/types-ts-module-tag.md deleted file mode 100644 index 612a827c1..000000000 --- a/todo/types-ts-module-tag.md +++ /dev/null @@ -1,41 +0,0 @@ -## `@module` in `types.ts` - -**Priority:** P4 -**Status:** open - -### Problem - -[`fjs/AGENTS.md`](../fjs/AGENTS.md) §2 says 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". - -90 of the 94 `types.ts` files in the tree carry it anyway. The rule and the -practice have been contradicting each other long enough that a new file copying -its neighbours lands on the wrong side of the documented convention, which is how -this was noticed: a review bot flagged `fjs/web/types.ts` for a tag every sibling -also has ([#1693](https://github.com/functionalscript/functionalscript/pull/1693)). - -### Proposal - -Decide which one is right, then make the tree say it once: - -- if the rule is right, strip `@module` from the 90 files — a mechanical change, - and worth checking against declaration emit first, since the leading block is - what carries a `types.d.ts` file's documentation; -- if the practice is right, amend `fjs/AGENTS.md` to say that a `types.ts` - companion is a module in its own right and carries the tag. - -The second reading has something going for it: `types.ts` *is* the entry point of -the type-level API, and its emitted `types.d.ts` is what a package consumer -reads. Whichever way it goes, the point is that it stops being a coin flip per -file. - -### Tasks - -- [ ] Decide the convention. -- [ ] Apply it to every `types.ts`, or amend `fjs/AGENTS.md`. -- [ ] Make `fjs/web/types.ts` match the outcome — it is currently the minority. - -### Related - -- `fjs/AGENTS.md` §2 — the rule as written. From fd4d6d2dcc022d907d8dfd019c0e6bfd9725fa5d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:45:30 +0000 Subject: [PATCH 258/370] todo: canonical numbers are ToString(Number); serializer rejects cycles Two review findings: "shortest round-trip spelling" does not select unique bytes (1e3 vs 1E3), so the canonical number spelling is exactly ECMAScript's deterministic ToString(Number) algorithm, restated in the spec, with -0 as the one stated exception (ToString spells it 0, canonical DataJS spells it -0); and the serializer's input is a live unfrozen value that may be cyclic, so cycle detection and a proved rejection are required - a DataJS document can only represent a DAG. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 1df813128..e20b9b106 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -174,8 +174,17 @@ finishes first and is `_0`, `parent` is `_1`; primitives are always emitted inline and never hoisted, since primitive sharing is unobservable and a value-equality ref counter would face the `0`/`-0` and `NaN` merging ambiguity that the `Object.is` -round-trip guarantee forbids; shortest round-trip number -spelling; bigints as full digits + `n`; fixed string escaping). Normalization +round-trip guarantee forbids; the canonical number spelling is exactly +ECMAScript's `ToString(Number)` — a fully deterministic algorithm the spec +restates, so no "shortest spelling" tie such as `1e3` vs `1E3` exists, +`ToString` never produces the uppercase form — with one stated exception, +`-0`, which `ToString` spells `0` and canonical DataJS spells `-0`; +bigints as full digits + `n`; fixed string escaping). The serializer's +*input* is a programmatic value that is not frozen and may be cyclic +(`value.self = value`); DataJS represents DAGs only, so the serializer +detects cycles and rejects them as an error — never emitting a +self-referencing `const _0={"self":_0};` (a TDZ failure in JS) and never +recursing unboundedly — with rejection proofs in stage 4. Normalization is not a blocker for the format spec. The serializer cannot delegate numbers to `JSON.stringify` (it loses `-0` and non-finite values); DataJS owns its number writer. The canonical layout is **one line** — fully minified, with From 69895e2abe2c328f8f356969a43f97ce84435859 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:47:31 +0000 Subject: [PATCH 259/370] emergent_testing: the todo says one thing about browser scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5 still asked for an interpreter "with no scheduling policy of its own", which the frame budget contradicts. It now says which half of that holds — the traversal schedules nothing, and no count of proofs belongs anywhere — and why the one policy it does carry is a statement about a host with a UI thread. The constraint that anticipated this is marked as met rather than left reading as a rule waiting on an event: a problem was reported, the change was measured in a real browser, and the boundary is per leaf on a frame budget rather than per N proofs, exactly as it required. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/share-browser-console-runner.md | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index cadc0d637..7bc3df94c 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -193,12 +193,19 @@ and is reviewable without the next one. and this took it. `fjs t` gained the behaviour in the process, which is what made that change worth landing on its own rather than inside the port. -- [x] **5. A browser interpreter** for exactly those operations, with no - scheduling policy of its own. `fjs/effects/browser/module.mjs`: - `sandbox`, `catch`, `all`, plus whatever operations the application adds - — for the page, one `report`. `sandbox` is `effects/node`'s, copied - rather than redesigned, because two runners that disagreed about an - awaited leaf would not be one runner. +- [x] **5. A browser interpreter** for exactly those operations. + `fjs/effects/browser/module.mjs`: `sandbox`, `catch`, `all`, plus + whatever operations the application adds — for the page, one `report`. + `sandbox` is `effects/node`'s, copied rather than redesigned, because two + runners that disagreed about an awaited leaf would not be one runner. + + This step asked for an interpreter "with no scheduling policy of its + own", and that was half right in a way worth keeping. The *traversal* has + none, which is what the whole issue is about, and nothing about a batch of + proofs belongs here. But an interpreter for a host with a UI thread must + give that thread back, or the host cannot paint — so `sandbox` carries one + policy, a frame budget, which is a statement about the browser and not + about proofs. The Tasks list below records what that cost to learn. - [x] **6. One reporter.** The event stream — a leaf landed, a run ended — that both hosts subscribe to. Step 2 gave them the *value*; this gave them the seam it travels through. `Reporter.result` now receives the @@ -414,6 +421,13 @@ paint, that is a separate, measured change with its own issue — and the measur is a boundary per unit of work, never a tuned count of proofs, because proofs differ in cost by orders of magnitude. +That is what happened, and this paragraph turned out to be right on every +count. The problem was reported — a page frozen for the length of a run, with +the browser offering to kill it — the change was measured in a real browser +before and after, and the boundary is per leaf on a frame budget rather than +per N proofs. It lives in the interpreter, where a statement about a host +belongs, and the traversal still schedules nothing. + An executor boundary will still be necessary because the console runner uses the Effects sandbox while a browser catches synchronous throws and awaits native promises. That boundary should answer one normalized leaf result. Tree From f8b224d1310b111ca7f6f70d18869e6079008df6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:49:03 +0000 Subject: [PATCH 260/370] ci: module header, co-located proof, and refuse a compiler range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more review findings, all correct. The module header lacked @module. fjs/AGENTS.md §2 requires it on every module.* entry point, and all five sibling fjs/ci modules carry one; #1756 removed the tag from non-module files, not from these. The proof lived in the parent. fjs/ci/deno, fjs/ci/nix and fjs/ci/node all have co-located proofs, so the precedent is the opposite of what I assumed. The job's own shape is now proved in fjs/ci/package/proof.f.mjs, and the parent keeps only what the assembled workflow can show: that the job is wired in, and that the job it waits for is really the one that uploads — an edge pointing at a job that never produces the artifact would satisfy the ordering and still never run. The compiler pin was the substantive one. Reading devDependencies.typescript gives a dependency specification, not a resolved version: this repository pins =7.0.2, but a project writing ^7.0.0 would have had npm choose, which is exactly the determinism the checkout-less job exists to preserve. The script now compares the installed version against the literal pin and refuses when they differ. That case is worse than it looks, and the test shows why: ^7.0.0 installs 7.0.2 today, so a range is indistinguishable from an exact pin until the day the registry publishes 7.0.3 and the verdict moves with no change to the package. Verified: ^7.0.0 exits non-zero, =7.0.2 exits 0 at 396 declarations, and a second tarball named other-package still resolves and passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 2 +- fjs/ci/package/module.f.mjs | 9 ++++++ fjs/ci/package/proof.f.mjs | 55 +++++++++++++++++++++++++++++++++++++ fjs/ci/proof.f.mjs | 37 ++++--------------------- 4 files changed, 70 insertions(+), 33 deletions(-) create mode 100644 fjs/ci/package/proof.f.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f78357cf..c3b8d4036 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -539,7 +539,7 @@ } }, { - "run": "set -eu\nnpm init -y > /dev/null\nnpm install --no-audit --no-fund ./*.tgz\n# The artifact installs under its own package name, which is not necessarily\n# this repository's: `fjs ci` generates workflows for other projects too. A\n# hard-coded name would fail for them, or worse, silently check a dependency\n# that happens to share the name instead of the artifact just built.\npkg=$(node -p \"Object.keys(require('./package.json').dependencies)[0]\")\n# The compiler is the package's own pin, read out of the packed package.json:\n# with no checkout there is no lockfile, so an unpinned install would let the\n# registry change this check's verdict with no change to the repository.\nts=$(PKG=\"$pkg\" node -p \"require('./node_modules/' + process.env.PKG + '/package.json').devDependencies?.typescript ?? ''\")\n# Refuse rather than fall back to a floating compiler.\ntest -n \"$ts\"\nnpm install --no-audit --no-fund \"typescript@$ts\"\n# Every declaration the package ships, enumerated from the installed artifact.\n# A hand-written import list cannot see a module that gains a private type\n# module later, which is the case this check exists to catch.\nfind \"node_modules/$pkg\" \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt\n# An empty list would type-check nothing and pass, which is the one way this\n# job can look healthy while checking nothing at all.\ntest -s declarations.txt\n# skipLibCheck stays at its false default: it is what makes tsc open these\n# declarations and report a reference the tarball does not carry.\nnpx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt" + "run": "set -eu\nnpm init -y > /dev/null\nnpm install --no-audit --no-fund ./*.tgz\n# The artifact installs under its own package name, which is not necessarily\n# this repository's: `fjs ci` generates workflows for other projects too. A\n# hard-coded name would fail for them, or worse, silently check a dependency\n# that happens to share the name instead of the artifact just built.\npkg=$(node -p \"Object.keys(require('./package.json').dependencies)[0]\")\n# The compiler is the package's own pin, read out of the packed package.json:\n# with no checkout there is no lockfile, so an unpinned install would let the\n# registry change this check's verdict with no change to the repository.\nts=$(PKG=\"$pkg\" node -p \"require('./node_modules/' + process.env.PKG + '/package.json').devDependencies?.typescript ?? ''\")\n# Refuse rather than fall back to a floating compiler.\ntest -n \"$ts\"\nnpm install --no-audit --no-fund \"typescript@$ts\"\n# A dependency specification is not a resolved version: `^7.0.0` installs\n# whatever the registry publishes next. Compare what was installed against the\n# literal pin and refuse when they differ, so the verdict cannot move without a\n# change to the package.\ninstalled=$(node -p \"require('./node_modules/typescript/package.json').version\")\nexact=$(SPEC=\"$ts\" node -p \"const s = process.env.SPEC; s.startsWith('=') ? s.slice(1) : s\")\ntest \"$installed\" = \"$exact\"\n# Every declaration the package ships, enumerated from the installed artifact.\n# A hand-written import list cannot see a module that gains a private type\n# module later, which is the case this check exists to catch.\nfind \"node_modules/$pkg\" \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt\n# An empty list would type-check nothing and pass, which is the one way this\n# job can look healthy while checking nothing at all.\ntest -s declarations.txt\n# skipLibCheck stays at its false default: it is what makes tsc open these\n# declarations and report a reference the tarball does not carry.\nnpx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt" } ] }, diff --git a/fjs/ci/package/module.f.mjs b/fjs/ci/package/module.f.mjs index 3d7d40841..3028fda07 100644 --- a/fjs/ci/package/module.f.mjs +++ b/fjs/ci/package/module.f.mjs @@ -2,6 +2,8 @@ * The packed-package check: a job that consumes the `npm pack` artifact the * way an outside consumer would. * + * @module + * * @import { Job } from '../common/types.ts' */ @@ -32,6 +34,13 @@ ts=$(PKG="$pkg" node -p "require('./node_modules/' + process.env.PKG + '/package # Refuse rather than fall back to a floating compiler. test -n "$ts" npm install --no-audit --no-fund "typescript@$ts" +# A dependency specification is not a resolved version: \`^7.0.0\` installs +# whatever the registry publishes next. Compare what was installed against the +# literal pin and refuse when they differ, so the verdict cannot move without a +# change to the package. +installed=$(node -p "require('./node_modules/typescript/package.json').version") +exact=$(SPEC="$ts" node -p "const s = process.env.SPEC; s.startsWith('=') ? s.slice(1) : s") +test "$installed" = "$exact" # Every declaration the package ships, enumerated from the installed artifact. # A hand-written import list cannot see a module that gains a private type # module later, which is the case this check exists to catch. diff --git a/fjs/ci/package/proof.f.mjs b/fjs/ci/package/proof.f.mjs new file mode 100644 index 000000000..6e974dfdf --- /dev/null +++ b/fjs/ci/package/proof.f.mjs @@ -0,0 +1,55 @@ +import { packageCheckJob, packageCheckJobId } from './module.f.mjs' +import { packageArtifact, packageJobId } from '../node/module.f.mjs' +import { assert, assertEq } from '../../asserts/module.f.mjs' + +/** @type {(fragment: string) => boolean} */ +const scriptHas = fragment => + packageCheckJob.steps.some(step => step.run?.includes(fragment) === true) + +export const proof = { + // The defining property. With a checkout there is a tsconfig.json up the + // tree, a node_modules to resolve into, and source files that can stand in + // for a declaration the tarball omits — the check would then pass on the + // repository rather than on the package. + noCheckout: () => { + assertEq(packageCheckJobId, 'package-check') + assert( + !packageCheckJob.steps.some(step => step.uses?.startsWith('actions/checkout@') === true), + 'the package check must not check out the repository') + }, + consumesTheArtifact: () => { + // Ordered after the producer: without this the two race and the + // download fails before the check has run. + assertEq(packageCheckJob.needs?.[0], packageJobId) + assertEq(packageCheckJob.needs?.length, 1) + // Downloaded by the name the producer exports, not a second literal + // that can drift from it. + const download = packageCheckJob.steps.find( + step => step.uses?.startsWith('actions/download-artifact@') === true) + assertEq(download?.with?.name, packageArtifact) + }, + // Each of these is what makes the job able to fail at all, and each has a + // silent failure mode rather than a loud one. + canFail: () => { + // `true` stops the checking without saying so. + assert(scriptHas('--skipLibCheck false'), 'expected skipLibCheck left false') + // An empty list type-checks nothing and passes. + assert(scriptHas('test -s declarations.txt'), 'expected a guard against an empty file list') + // A range is not a resolved version, so an installed compiler that does + // not match the pin means the registry, not the package, decided. + assert(scriptHas('test "$installed" = "$exact"'), 'expected the installed compiler matched against the pin') + }, + // `fjs ci` generates workflows for other projects, so the artifact's + // package name is whatever that project publishes. A hard-coded name would + // fail for them — or worse, silently check a dependency that happens to + // share the name instead of the artifact just built. + anyPackageName: () => { + assert( + scriptHas("Object.keys(require('./package.json').dependencies)[0]"), + 'expected the package directory derived from the artifact') + assert(scriptHas('find "node_modules/$pkg"'), 'expected declarations enumerated from that directory') + assert( + !scriptHas('node_modules/functionalscript'), + 'the package check must not hard-code this repository\'s package name') + }, +} diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index 691d8a4a4..8d1815c31 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -257,45 +257,18 @@ export const proof = { }, packageCheck: () => { const gha = run(false) + // The job's own shape is proved next to the module, in + // `fjs/ci/package/proof.f.mjs`. What only the assembled workflow can + // show is that it is wired in, and that the job it waits for is really + // the one that produces the artifact — an edge pointing at a job that + // never uploads would satisfy the ordering and still never run. const job = gha.jobs[packageCheckJobId] assert(job !== undefined, 'expected the packed-package check job') - // The defining property. With a checkout there is a tsconfig.json up - // the tree, a node_modules to resolve into, and source files that can - // stand in for a declaration the tarball omits — the check would then - // pass on the repository rather than on the package. - assert( - !job.steps.some(step => step.uses?.startsWith('actions/checkout@') === true), - 'the package check must not check out the repository') - // Ordered after the producer, and the producer is the job that - // actually uploads — asserting the edge alone would not catch it - // pointing at a job that never produces the artifact. assertEq(job.needs?.[0], packageJobId) assert( gha.jobs[packageJobId]?.steps.some( step => step.uses?.startsWith('actions/upload-artifact@') === true) === true, 'expected the needed job to be the one that uploads') - // Downloaded by the name the producer exports, not a second literal. - const download = job.steps.find( - step => step.uses?.startsWith('actions/download-artifact@') === true) - assertEq(download?.with?.name, packageArtifact) - // The properties that decide whether this job can fail at all. Each is - // load-bearing: `skipLibCheck` true silently stops the checking, - // enumerating from the installed package is what sees a module that - // gains a private type module later, and an empty file list would - // type-check nothing and pass. - assert(hasRunInJob(packageCheckJobId, '--skipLibCheck false')(gha), 'expected skipLibCheck left false') - assert(hasRunInJob(packageCheckJobId, 'find "node_modules/$pkg"')(gha), 'expected declarations enumerated from the artifact') - assert(hasRunInJob(packageCheckJobId, 'test -s declarations.txt')(gha), 'expected a guard against an empty file list') - // The compiler is the package's own pin: with no checkout there is no - // lockfile, so an unpinned install lets the registry change the verdict. - assert(hasRunInJob(packageCheckJobId, 'devDependencies?.typescript')(gha), 'expected the compiler pinned from the packed package.json') - // `fjs ci` generates workflows for other projects, so the artifact's - // package name is whatever that project publishes. A hard-coded name - // would fail for them — or worse, silently check a dependency that - // happens to share the name instead of the artifact just built, which - // is a plausible wrong answer rather than a refusal. - assert(hasRunInJob(packageCheckJobId, "Object.keys(require('./package.json').dependencies)[0]")(gha), 'expected the package directory derived from the artifact') - assert(!hasRunInJob(packageCheckJobId, 'node_modules/functionalscript')(gha), 'the package check must not hard-code this repository\'s package name') }, jobNeeds: () => { const steps = /** @type {const} */ ([{ run: 'echo hi' }]) From 0cd6ace67aefca30cdccc3168ed085544c79c7d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:50:35 +0000 Subject: [PATCH 261/370] todo: every statement ends with ';', export default included Design change: one uniform terminator rule instead of a rule plus an export-default exception - simpler to spec and implement, still a JS subset since export default value; is valid JS. The grammar, the one-line example, and the JSON-to-DataJS conversion (now "export default " + json + ";") follow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index e20b9b106..54171b4d8 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -112,18 +112,19 @@ other implementations may. ```text module ::= const* export const ::= 'const' id '=' value ';' -export ::= 'export' 'default' value (no trailing ';') +export ::= 'export' 'default' value ';' value ::= primitive | id | array | object key ::= string | '[' '"__proto__"' ']' ``` -- **`;` terminates every `const`;** no `;` after `export default`, no empty +- **`;` terminates every statement, `export default` included** — one + uniform rule, no per-statement exception; no empty statements. Rationale, each sufficient alone: no line-terminator taxonomy in the spec (a lone CR *is* a JS `LineTerminator` — trivia no implementer should need); one canonical spelling per document; the separator is a visible character, so byte-different files that render identically cannot differ in meaning; and a document minifies to one line — - `const a=[];export default[a,a]` — enabling DataJS inside JSON strings, + `const a=[];export default[a,a];` — enabling DataJS inside JSON strings, line-delimited streaming, and one-line test fixtures. Whitespace is needed only between adjacent word-tokens (`const a`, `export default x`). - **Whitespace is JSON's** — space, tab, LF, CR — insignificant everywhere. @@ -153,7 +154,7 @@ key ::= string | '[' '"__proto__"' ']' than citing ECMA-262. - **Every JSON value is a DataJS value; no JSON document is a DataJS document** (a DataJS document is a JS module, so it cannot be a JSON - document). The textual conversion `"export default " + json` yields a + document). The textual conversion `"export default " + json + ";"` yields a valid document with one exception: a bare `"__proto__"` object key — rejected by DataJS because JS reads it as prototype replacement — must be rewritten to the computed spelling `["__proto__"]` during conversion. From 1ee9e89e3ede6c557cb9d3284455c8ca690b8ce8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:51:29 +0000 Subject: [PATCH 262/370] todo: serializer validates the whole data model; -0 already parses Two review findings: serializer input validation generalizes beyond cycles - any value outside the DataJS data model is rejected rather than approximated (foreign leaves, sparse-array holes, symbol-keyed and accessor properties, cycles), each with a rejection proof; and stage 5 no longer directs reimplementing exact -0, which the current front end already parses correctly (lexeme pinned in the tokenizer proof, parseFloat preserves signed zero) - it gets a regression proof instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 54171b4d8..7438fac72 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -181,11 +181,15 @@ restates, so no "shortest spelling" tie such as `1e3` vs `1E3` exists, `ToString` never produces the uppercase form — with one stated exception, `-0`, which `ToString` spells `0` and canonical DataJS spells `-0`; bigints as full digits + `n`; fixed string escaping). The serializer's -*input* is a programmatic value that is not frozen and may be cyclic -(`value.self = value`); DataJS represents DAGs only, so the serializer -detects cycles and rejects them as an error — never emitting a -self-referencing `const _0={"self":_0};` (a TDZ failure in JS) and never -recursing unboundedly — with rejection proofs in stage 4. Normalization +*input* is a programmatic value that is not frozen, so it must be validated +against the DataJS data model, and anything outside the model is rejected +as an error rather than approximated: a leaf outside the leaf set (a +function, a symbol, a `Date` or any other non-plain object), a sparse +array's hole (which is not an `undefined` element), a symbol-keyed or +accessor own property (reading a getter is an effect), and a cycle +(`value.self = value`) — DataJS represents DAGs only, and treating a +back-edge as sharing would emit a self-referencing `const _0={"self":_0};`, +a TDZ failure in JS. Rejection proofs in stage 4 cover each case. Normalization is not a blocker for the format spec. The serializer cannot delegate numbers to `JSON.stringify` (it loses `-0` and non-finite values); DataJS owns its number writer. The canonical layout is **one line** — fully minified, with @@ -252,10 +256,13 @@ throughout. top-level `module.f.mjs`/`proof.f.mjs` carrying `compile()` move with the front end to `fsc`. Separator `nl` → `';'`; reserved words added; the DataJS numeric leaves taught to the moved front end — `NaN`, - `Infinity`, `-Infinity`, and exact `-0` are unresolved identifiers in + `Infinity`, and `-Infinity` are unresolved identifiers in today's parser, so reserving the names alone would *reject* DataJS accept - vectors: tokenizer, grammar, minus-folding, and AST/evaluation support is - stage-5 work (the front-end half of + vectors: their tokenizer, grammar, minus-folding, and AST/evaluation + support is stage-5 work; exact `-0` already parses correctly (the + tokenizer pins the `-0` lexeme and `parseFloat` preserves signed zero), + so it needs a regression proof, not reimplementation (together the + front-end half of [compile-modules-to-edag](../fjs/djs/todo/compile-modules-to-edag.md)'s special-number requirement), a precondition of stage 6's subset proofs; `fjs compile` repointed. The EDAG staging continues under the `fsc` From 4eb70127829e2dbdacbad2d7a5e8c1bc8b540ef4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:56:34 +0000 Subject: [PATCH 263/370] effects/browser: charge every operation to the frame budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only `sandbox` was charged, so a page whose proofs are trivial and whose reporting paints a row spent its time where nothing was watching: a hundred cheap leaves start inside one slice, and the hundred renders that follow drain as microtasks with no turn given back. Whatever this runner dispatches runs on the host's one thread, so every operation is charged now, the ones a host adds included — that is the host's own work, and this is the point that knows it. `sandbox` loses its special case, and `all` still cannot be the yield point: it must start every child before awaiting any. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/module.mjs | 61 +++++++++++++++++--------- fjs/emergent_testing/browser/proof.mjs | 24 ++++++++++ 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs index 44b34a885..0e1985b60 100644 --- a/fjs/effects/browser/module.mjs +++ b/fjs/effects/browser/module.mjs @@ -181,29 +181,45 @@ export const browserRun = extra => { } return slice } + /** + * Charges a handler's work to the budget: it waits when the slice is spent, + * and does not otherwise. + * + * **Every operation is wrapped, not only `sandbox`.** Whatever a runner + * dispatches runs on the host's one thread, so the leaf is not the only + * thing that can hold it: a page whose proofs are trivial and whose + * reporting paints a row spends its time in `report`, and a budget that + * only watched the leaf would let a hundred cheap leaves start inside one + * slice and then drain a hundred paints with no turn given back. The + * operation a host adds is the host's own work, and this is the point that + * knows it. + * + * @type {(handler: (...a: A) => Promise) => (...a: A) => Promise} + */ + const budgeted = handler => async (...payload) => { + let wait = overBudget() + while (wait !== null) { + await wait + wait = overBudget() + } + return handler(...payload) + } const core = { all: async (/** @type {readonly any[]} */ ...effects) => ok(await Promise.all(effects.map(run))), - // **The leaf is where a browser run yields, and it has to be.** `all` - // starts every child before awaiting any — a contract, not an - // implementation detail — so it cannot pause between them without - // hanging a graph whose child waits on a later sibling. That leaves the - // leaf: it is the one point every unit of work passes through, and it - // holds no sibling's answer while it waits. + // **Where the yield cannot be.** `all` starts every child before + // awaiting any — a contract, not an implementation detail — so it + // cannot pause *between* children without hanging a graph whose child + // waits on a later sibling. The budget is charged as each operation is + // dispatched instead, which holds no sibling's answer while it waits. // - // Without this the whole suite runs as one task. Leaves resolve through - // microtasks, and a microtask drain never returns to the event loop, so - // a page cannot paint a result, service a timer or answer a click from - // the first proof to the last — measured at ~53 s on this repo's own - // browser suite, long enough for the browser to offer to kill the page. - sandbox: async (/** @type {() => unknown} */ f) => { - let wait = overBudget() - while (wait !== null) { - await wait - wait = overBudget() - } - return ok(await sandbox(f)) - }, + // Without any of this the whole suite runs as one task. Everything + // resolves through microtasks, and a microtask drain never returns to + // the event loop, so a page cannot paint a result, service a timer or + // answer a click from the first proof to the last — measured at ~53 s + // on this repo's own browser suite, long enough for the browser to + // offer to kill the page. + sandbox: async (/** @type {() => unknown} */ f) => ok(await sandbox(f)), // No clock and no fixture convention — see `Catch` in // `../node/types.ts` for why this is a second operation beside // `sandbox` rather than a use of it. It is `tryCatch`, spelled the @@ -235,6 +251,11 @@ export const browserRun = extra => { if (claimed.length !== 0) { throw `browserRun: ${claimed.join(', ')} already implemented` } - run = asyncRun(/** @type {any} */ (Object.defineProperties({ ...core }, handlers))) + const merged = Object.defineProperties({ ...core }, handlers) + run = asyncRun(/** @type {any} */ (Object.fromEntries( + Object.getOwnPropertyNames(merged).map(k => [ + k, + budgeted(/** @type {any} */ (/** @type {any} */ (merged)[k])), + ])))) return run } diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index b749150b5..1a2901160 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -354,6 +354,30 @@ export const proof = { assertStructurallySame([...p.states], ['running', 'failed']) assertEq(p.view.events.length, 1) }, + // **Reporting is work on the page's thread too.** A hundred trivial leaves + // fit inside one slice, so a budget that watched only the leaf would let + // them all start and then drain a hundred renders with no turn given back. + // The message queued before the run is the page's stand-in for a paint: it + // has to arrive while the run is still going. + reportingIsChargedToTheBudget: async () => { + const proof = Object.fromEntries( + Array.from({ length: 100 }, (_, index) => [`t${index}`, () => undefined])) + let deliveredDuringRun = false + let finished = false + const { port1, port2 } = new MessageChannel() + port1.onmessage = () => { deliveredDuringRun = !finished } + port2.postMessage(undefined) + const report = await runBrowserProofs([['m', proof]], () => { + // A renderer that costs something, as appending a row does. + const end = performance.now() + 1 + while (performance.now() < end) { /* paint the row */ } + }) + finished = true + port1.close() + port2.close() + assertEq(report.totals.passed, 100) + assert(deliveredDuringRun) + }, // A parent precedes the children its return value produced, however deep // the chain goes — the records are joined as a rope and walked out once, // so nesting must not reorder them the way a per-level rebuild could. From 8ff9dd8b894b5dae5c8781176fede4846654190b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:57:13 +0000 Subject: [PATCH 264/370] todo: name the seam generalization; minus folding is parameterized Two review findings on implementation feasibility: stage 4 now states that today's JSON parser seam is too narrow for DataJS (NumberPolicy sees number tokens only, no identifier/bigint tokens, string keys only) and makes generalizing it - token vocabulary, leaf/identifier policy hook, key-form hook, with JSON behavior pinned unchanged by proofs - explicit prerequisite work; and minus folding becomes a parameterized helper whose strict JSON instantiation folds a number only, while DataJS's adds -Infinity and negative bigint, so the extra sign forms never enter the JSON tokenizer. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 7438fac72..98818aa2e 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -243,10 +243,18 @@ throughout. in `fjs/media/json/tokenizer` with a scanner of JSON's own lexical grammar, exporting the string and number scanners for reuse. Accepted-input proofs unchanged; error-shape proofs rewritten once. -4. **`fjs/media/datajs`** — parser (JSON's container machine via its policy - seam, plus an identifier policy) and serializer (the shared walker of +4. **`fjs/media/datajs`** — parser and serializer, proofs over the spec + vectors. The parser reuses JSON's container machine, and today's seam is + **not wide enough for that**: `NumberPolicy` receives number tokens only, + `JsonToken` has no identifier/bigint/`=` tokens, and the object states + accept string keys only. Generalizing the seam is therefore explicit + stage-4 prerequisite work on `fjs/media/json/parser`: extend the token + vocabulary the machine can be fed, add the leaf/identifier policy hook + (JSON's instantiation: error) and the key-form hook (JSON's: string keys + only), and pin JSON's accepted language and behavior unchanged by proofs + across the API change. The serializer is the shared walker of [157](../fjs/djs/todo/157-json-djs-shared-value-machine.md) §2 with a - ref-lookup hook, own number writer), proofs over the spec vectors. + ref-lookup hook and DataJS's own number writer. 5. **Front-end move** — `fjs/djs/{tokenizer,parser,ast,transpiler}` → `fjs/fsc/*` as a rename. The rest of `fjs/djs` has stated destinations rather than following the rename: `serializer/` is reworked into stage @@ -312,8 +320,11 @@ throughout. - [157-json-djs-shared-value-machine](../fjs/djs/todo/157-json-djs-shared-value-machine.md) — §2's shared-walker extraction becomes stage 4 work; §3's minus-rewriter - question is settled by stage 3 (the folding lives in JSON's own tokenizer - and DataJS reuses it). Rebase the issue on this plan or fold it in. + question is settled by stages 3–4: the folding is a parameterized helper + whose strict JSON instantiation folds `-` before a number token only + (JSON's acceptance unchanged), while DataJS's instantiation adds its own + cases (`-Infinity`, negative bigint) — the extra sign forms never enter + the JSON tokenizer. Rebase the issue on this plan or fold it in. - [663-json-djs-tree-type](../fjs/djs/todo/663-json-djs-tree-type.md) — the shared `Tree

` instantiation targets `fjs/media/datajs`; rename paths. - [bnf-grammar-single-owner](../fjs/media/json/todo/bnf-grammar-single-owner.md) From 59fea55cd53bcc10df9765ff8e2cdfd242ccc75f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:59:48 +0000 Subject: [PATCH 265/370] rtti: decide presence before bounding, so no reader diverges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #1766: the length gate added an observable read ahead of the presence decisions, so a mutating length getter could steer validate and parse into accepting a value the data form rejects. AGENTS.md 5 does not allow deferring a regression behind a todo, and reader disagreement is what this codebase has consistently treated as the defect, so the gate is restructured rather than documented. Each declared member's presence is now decided first — a HasProperty probe that recurses into nothing — then the container is bounded, then the members are read, and presenceUnchanged re-asks the *pre-bound* snapshot against the final state. A length getter that deletes a declared member is therefore caught on every reader. The bound still precedes all recursion, so the exponential fix stands: an or() of two arities settles by the bound for the value that is too long and by the absent last member for the one that is too short. 400 levels in 35ms. host.proof.mjs gains lengthGetterCannotSteerTheVerdict, which fails against the previous commit; each reader gets its own instance, since sharing one lets the first reader absorb the one-shot mutation. parse gains its own precedence proof, and the todo bullet describing the divergence is removed because the divergence is gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- changelog/unreleased/1766.md | 9 +-- fjs/rtti/host.proof.mjs | 42 ++++++++++++++ fjs/rtti/parse/module.f.mjs | 45 +++++++-------- fjs/rtti/parse/proof.f.mjs | 10 ++++ .../hostile-accessor-hermetic-read-path.md | 10 ---- fjs/rtti/validate/module.f.mjs | 56 +++++++++++-------- 6 files changed, 109 insertions(+), 63 deletions(-) diff --git a/changelog/unreleased/1766.md b/changelog/unreleased/1766.md index dbb6bc38f..c4004fd5d 100644 --- a/changelog/unreleased/1766.md +++ b/changelog/unreleased/1766.md @@ -1,4 +1,5 @@ -- `rtti`: `validate` and `parse` bound a closed tuple or struct by length before - reading its members, so an `or` of two arities no longer walks shared operands - once per arm. Acceptance is unchanged; a value that is both too long and wrong - at a member now reports the container-level error rather than the member's. +- `rtti`: `validate` and `parse` decide a closed tuple's or struct's member + presence, then bound it by length, before reading any member — so an `or` of + two arities no longer walks shared operands once per arm. Acceptance is + unchanged; a value that is both too long and wrong at a member now reports + the container-level error rather than the member's. diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index 1a3fec618..a43e343ce 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -85,6 +85,29 @@ const shadowedIndex = () => { */ const beyondIndexRange = () => Object.assign([1], { '4294967295': 2 }) +/** + * An array whose **first** `length` read deletes a declared member, which is + * the shape that catches a reader bounding a container before it has decided + * what its members are. `Array.isArray` sees through the proxy, so all three + * readers take their array path. + * + * @type {() => readonly Unknown[]} + */ +const lengthGetterDeletesAMember = () => { + const target = ['bad'] + let fired = false + return new Proxy(target, { + get: (o, k, r) => { + if (k === 'length' && !fired) { + fired = true + delete o[0] + } + return Reflect.get(o, k, r) + }, + }) +} + + export const proof = { // `undeclaredMembers` decides a container's members by what an index // *reads*, so both of these are members — one that an own-entry walk @@ -141,6 +164,25 @@ export const proof = { assertEq(rv[0], d(t)(value)[0], 'the data form must agree too') } }, + // A `length` getter that deletes a declared member. The readers bound a + // container by its length, so this getter fires inside their walk — and + // each reader needs its **own** instance, because the mutation is + // one-shot: share it and the first reader absorbs it, leaving the others + // a value that is merely short. Presence is decided before the bound is + // read and re-asked against the final state, so the deletion is caught on + // every reader instead of steering one of them into an accept. + lengthGetterCannotSteerTheVerdict: () => { + /** @type {readonly Type[]} */ + const schemas = [[or(option, number)], [number]] + for (const t of schemas) { + const rv = v(t)(lengthGetterDeletesAMember()) + assertEq(rv[0], p(t)(lengthGetterDeletesAMember())[0], 'validate and parse must agree') + assertEq(rv[0], d(t)(lengthGetterDeletesAMember())[0], 'the data form must agree too') + // and what they agree on: the member the schema declared was + // there when it was decided, so losing it is a rejection + assertError(rv) + } + }, // …and what they agree on, which is what the changelog entry claims. inheritedIndexMeetsTheRest: () => { const value = inheritedIndex() diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index e12636d02..b8d8d93af 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -58,8 +58,10 @@ import { ok } from '../../types/result/module.f.mjs' import { absentMember, + consPresence, constPrimitiveValidate, eachEntry, + emptyPresence, isArray, isObject, orVisit, @@ -340,30 +342,22 @@ const constContainerParse = if (!isContainer(value)) { return verror('unexpected value') } - // Bound the container before reading any member: a value the - // schema cannot fit is rejected whatever its members hold, so - // walking them first only decides *which* error to report. The - // same check is re-asked below, after the reads, so a value that - // changes under them is still caught — this only ever rejects - // earlier, never accepts more. - // - // It is load-bearing for an `or` of two arities, the shape a - // schema uses to say a trailing operand may be left out - // (`fjs/edag`'s chain nodes). Without it each arm walks the - // shared operands before failing on length, so validating a - // nested chain costs 2^depth; with it the arm is decided before - // any recursion. `parse` gates identically, which is what keeps - // the two readers reporting the same error. - // - // The read is an added observable operation, and under a hostile - // accessor that is not neutral: a `length` getter that mutates a - // declared member now fires *before* the members are read, where - // it used to fire after, so it can steer the verdict the readers - // then reach. That is the class - // `../todo/hostile-accessor-hermetic-read-path.md` tracks, and - // this gate adds one instance of it — see the bullet there. For - // every value whose `length` read is side-effect-free — every DJS - // value, and every ordinary array — acceptance is untouched. + // Presence first, then the bound, then the reads — see the + // comment on the same shape in `../validate/module.f.mjs`. The + // length read lands after the decisions it could otherwise + // steer, and bounding before the reads is what lets an `or` of + // two arities settle an arm without recursing. + const presence = eachEntry( + rttiEntries, + (k, t) => { + if (k in value) { return ok(true) } + const a = absentMember(t) + return a[0] === 'error' ? a : ok(false) + }, + emptyPresence, + consPresence, + ) + if (presence[0] === 'error') { return presence } if (!fits(value, declared.length)) { return verror('unexpected value') } @@ -384,7 +378,8 @@ const constContainerParse = if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { return verror('unexpected value') } - if (!presenceUnchanged(rttiEntries, r[1].presence, value)) { + // The pre-bound presence, as in `../validate/module.f.mjs`. + if (!presenceUnchanged(rttiEntries, presence[1], value)) { return verror('unexpected value') } const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(r[1].entries)) diff --git a/fjs/rtti/parse/proof.f.mjs b/fjs/rtti/parse/proof.f.mjs index 161f96f50..4ef5eb6d4 100644 --- a/fjs/rtti/parse/proof.f.mjs +++ b/fjs/rtti/parse/proof.f.mjs @@ -450,6 +450,16 @@ export const proof = { }, // A hole past the prefix is no member, so length is what catches it. holePastThePrefixRejected: () => assertError(parse([number])([1, ,])), + // The container is bounded before its members are read, so a + // value that is both too long and wrong at a member is answered + // by its shape. `validate` reports the same path — the two gate + // alike, which is what `../validate/proof.f.mjs`'s + // `sameAcceptanceAsParse` holds them to — and this pins it on + // `parse`'s own side. + structuralMismatchIsAnsweredFirst: () => { + assertErrorPath([])(parse([/** @type {const} */ (42)])([43, 'extra'])) + assertErrorPath(['0'])(parse([/** @type {const} */ (42)])([43])) + }, // Nor is a key that is no position at all. nonIndexKeyRejected: () => assertError(parse([number])(Object.assign([1], { foo: 2 }))), diff --git a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md index edf9d00f0..e92e2e37f 100644 --- a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md +++ b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md @@ -23,16 +23,6 @@ callers: check or a `rest` sees. The tuple length bound catches the simplest variant, but a `rest` kind can be steered into accepting a value whose undeclared members were never held to the rest. -- `constContainerValidate`/`constContainerParse` bound the container by - `length` **before** reading its members, which is what lets an `or` of two - arities decide an arm without recursing (`fjs/edag`'s chain nodes). A - `length` getter that mutates therefore fires before the presence decisions - rather than after: an array proxy over `['bad']` whose first - `get('length')` deletes index 0 is accepted by both readers against - `[or(option, number)]`, where the data form — reading the value its own way - — still rejects it. Measured; the two thunk readers agree with each other - throughout, so what a fix has to restore is their agreement with the data - form. - `visit` and `absenceIn` destructure the schema thunk's descriptor (`const [tag, ...operands] = rtti()`), which dispatches `Array.prototype[Symbol.iterator]` — patched, the accessor chooses the diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index 1fec81fc2..8f0d5e5eb 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -215,30 +215,35 @@ const constContainerValidate = if (!isContainer(value)) { return verror('unexpected value') } - // Bound the container before reading any member: a value the - // schema cannot fit is rejected whatever its members hold, so - // walking them first only decides *which* error to report. The - // same check is re-asked below, after the reads, so a value that - // changes under them is still caught — this only ever rejects - // earlier, never accepts more. + // Decide every declared member's **presence** first, then bound + // the container, and only then read the members. Presence is a + // `HasProperty` probe and recurses into nothing, so this pass is + // cheap; what it buys is that the length read happens after the + // decisions it could otherwise steer, and `presenceUnchanged` + // below still re-asks them against the final state — so a + // `length` getter that mutates a declared member is caught + // rather than obeyed, and the three readers keep agreeing. // - // It is load-bearing for an `or` of two arities, the shape a - // schema uses to say a trailing operand may be left out - // (`fjs/edag`'s chain nodes). Without it each arm walks the - // shared operands before failing on length, so validating a - // nested chain costs 2^depth; with it the arm is decided before - // any recursion. `parse` gates identically, which is what keeps - // the two readers reporting the same error. - // - // The read is an added observable operation, and under a hostile - // accessor that is not neutral: a `length` getter that mutates a - // declared member now fires *before* the members are read, where - // it used to fire after, so it can steer the verdict the readers - // then reach. That is the class - // `../todo/hostile-accessor-hermetic-read-path.md` tracks, and - // this gate adds one instance of it — see the bullet there. For - // every value whose `length` read is side-effect-free — every DJS - // value, and every ordinary array — acceptance is untouched. + // Bounding before the reads is load-bearing for an `or` of two + // arities, the shape a schema uses to say a trailing operand may + // be left out (`fjs/edag`'s chain nodes). Without it each arm + // walks the shared operands before failing on length, so + // validating a nested chain costs 2^depth; with it the arm is + // settled before any recursion — by the bound for the value that + // is too long, and by the absent last member for the one that is + // too short. `parse` does the same, which is what keeps the two + // readers reporting the same error. + const presence = eachEntry( + rttiEntries, + (k, v) => { + if (k in value) { return ok(true) } + const a = absentMember(v) + return a[0] === 'error' ? a : ok(false) + }, + emptyPresence, + consPresence, + ) + if (presence[0] === 'error') { return presence } if (!fits(value, declared.length)) { return verror('unexpected value') } @@ -261,7 +266,10 @@ const constContainerValidate = } // `value` is C (Unknown container), but Ts for T extends Tuple|Struct is not // structurally equivalent to C — TypeScript can't narrow element types through the loop. - return presenceUnchanged(rttiEntries, r[1], value) + // Against the presence decided *before* the bound was read, not + // the walk's own: that is what makes a mutating `length` getter a + // rejection rather than a steer. + return presenceUnchanged(rttiEntries, presence[1], value) ? /** @type {any} */ (ok(value)) : verror('unexpected value') } From a8b421a8cb5d4c960cdb58877d1440d0b75a2e6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:00:13 +0000 Subject: [PATCH 266/370] todo: canonical string escaping is QuoteJSONString Review finding: "fixed string escaping" left \n-vs- and slash-escaping divergence open. The canonical spelling is exactly ECMAScript's QuoteJSONString (what JSON.stringify emits for a string), restated in the spec, matching how canonical numbers anchor to ToString(Number). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- todo/parser-serializer-restructure.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 98818aa2e..ff30698c2 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -180,7 +180,13 @@ ECMAScript's `ToString(Number)` — a fully deterministic algorithm the spec restates, so no "shortest spelling" tie such as `1e3` vs `1E3` exists, `ToString` never produces the uppercase form — with one stated exception, `-0`, which `ToString` spells `0` and canonical DataJS spells `-0`; -bigints as full digits + `n`; fixed string escaping). The serializer's +bigints as full digits + `n`; canonical string escaping is exactly +ECMAScript's `QuoteJSONString` — what `JSON.stringify` emits for a string: +the minimal escapes `\"` `\\` `\b` `\t` `\n` `\f` `\r`, other control +characters as `\u00`·two lowercase hex digits, unpaired surrogates as +lowercase `\uXXXX`, everything else literal and `/` never escaped — again a +deterministic algorithm the spec restates rather than a "minimal escaping" +adjective). The serializer's *input* is a programmatic value that is not frozen, so it must be validated against the DataJS data model, and anything outside the model is rejected as an error rather than approximated: a leaf outside the leaf set (a From 7387fb1597434a0d35d84870cf7c0e6f4ee405d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:03:01 +0000 Subject: [PATCH 267/370] effects: record the argument limit on `all` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `All` is variadic, so every fan-out is a spread and a spread is a call. Measured on node 22: 50,000 siblings build, 100,000 throw `RangeError: Maximum call stack size exceeded` — in building the effect, before any interpreter sees it, so nothing can recover from it. It is a ceiling on one module's leaves rather than on a suite, and nothing here is close to it, so this records the limit and the list-shaped signature that removes it rather than changing every interpreter inside another PR. The browser used to avoid it by accident, spreading 25 at a time; that protection went with the batching, which means both runners now share one ceiling and one place to fix it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/all-argument-limit.md | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 fjs/effects/todo/all-argument-limit.md diff --git a/fjs/effects/todo/all-argument-limit.md b/fjs/effects/todo/all-argument-limit.md new file mode 100644 index 000000000..d837bb57f --- /dev/null +++ b/fjs/effects/todo/all-argument-limit.md @@ -0,0 +1,76 @@ +## all-argument-limit. `all` cannot fan out more siblings than the engine allows arguments + +**Priority:** P3 +**Status:** open + +### Problem + +`All` is declared variadic — `readonly['all', (...effects: Effect[]) => …]` — so +every fan-out reaches it as a spread. `allOk(...entries.map(one))` in +`emergent_testing/module.f.mjs` is one, `allOk(...modules.map(…))` beside it is another, +and both are how a run of any size is built. + +A spread is a call, and a call has an argument limit. Measured on node 22: + +| siblings | result | +|-|-| +| 50,000 | ok | +| 100,000 | `RangeError: Maximum call stack size exceeded` | + +The throw is in **building** the effect, before any interpreter sees it, so no runner can +recover from it and no `catch` operation is in the path. `fjs t` panics; the browser page +reports one `infrastructure-error` because it guards the run's own failure, which is the +guard working as intended but not an answer. + +This is a limit on *one module's* sibling leaves rather than on a suite: modules are +themselves siblings, so a suite of any size passes as long as no single module holds more +than a few tens of thousands of leaves. Nothing in this repository is close — the browser +suite is 3,461 leaves across 138 modules — so this is a real ceiling rather than a live +problem, and it is recorded rather than fixed for that reason. + +The browser runner used to avoid it accidentally: it fanned out in batches of 25, so it +never spread more than 25 arguments. That batching is gone +([share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md)), +deliberately and for good reasons, and with it went a protection nobody had asked for or +noticed. Both runners now share the ceiling, which is at least honest: one traversal, one +limit, one place to fix it. + +### Proposal + +Make `all` take a list rather than an argument list: + +```ts +export type All = readonly['all', (effects: readonly Effect[]) => OpResult[]>] +``` + +Then `allOk(entries.map(one))` builds an array and hands it over, and no call in the path +grows with the suite. Every interpreter changes shape — `effects/node`, `effects/browser`, +the mock, and any fixture that supplies an `all` handler — which is what makes this its own +step rather than a fix inside another change. + +The variadic spelling is nicer at the two-or-three-effect call sites that motivated it +(`both`, hand-written fan-outs in proofs), so a wrapper that keeps that shape over the +list-shaped operation is worth having in the same change. + +### Alternatives considered + +- **Chunk the traversal.** Fan out in groups below the limit. This puts a constant back + into the shared walk, which is the mistake + [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) + spends several pages on, and it changes the concurrency of every run to work around an + argument-passing detail. No. +- **Leave it.** Defensible today, and what this issue does for now. It stops being + defensible the first time a generated suite puts tens of thousands of leaves in one + module. + +### Tasks + +- [ ] Decide the list-shaped `All` signature and whether a variadic wrapper stays. +- [ ] Move every interpreter and fixture to it in one change. +- [ ] Prove a fan-out above the current ceiling — the number itself is engine-specific, so + the proof asserts that a large fan-out completes rather than asserting the ceiling. + +### Related + +- [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) + — where the browser's accidental protection was removed, and why. From 389c443c4e7a7215d7d266d973fd88e0bab4e430 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:05:41 +0000 Subject: [PATCH 268/370] ci: one step per stage in the package check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check was a single run: step, so a failure arrived as "the script failed" rather than naming which part. It is now four steps — install the artifact, install the pinned compiler, enumerate the declarations, type-check them — so the GitHub UI attributes a failure to the stage that produced it. Shell variables do not survive between steps. Only one value actually crosses a boundary: pkg, the artifact's package name, which now travels through $GITHUB_ENV. ts, installed and exact stay local to the compiler step, and declarations.txt and node_modules cross as files, which need no mechanism. Verified by running the four emitted scripts in sequence with $GITHUB_ENV emulated: all pass, pkg carries across, 396 declarations, TypeScript 7.0.2 — the same result the single script produced. The attribution works too: a range pin fails at the compiler step, and a dangling declaration fails at the type-check step with TS2307, rather than both surfacing as one opaque failure. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 11 ++++++++++- fjs/ci/package/module.f.mjs | 23 ++++++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3b8d4036..4aae9aa09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -539,7 +539,16 @@ } }, { - "run": "set -eu\nnpm init -y > /dev/null\nnpm install --no-audit --no-fund ./*.tgz\n# The artifact installs under its own package name, which is not necessarily\n# this repository's: `fjs ci` generates workflows for other projects too. A\n# hard-coded name would fail for them, or worse, silently check a dependency\n# that happens to share the name instead of the artifact just built.\npkg=$(node -p \"Object.keys(require('./package.json').dependencies)[0]\")\n# The compiler is the package's own pin, read out of the packed package.json:\n# with no checkout there is no lockfile, so an unpinned install would let the\n# registry change this check's verdict with no change to the repository.\nts=$(PKG=\"$pkg\" node -p \"require('./node_modules/' + process.env.PKG + '/package.json').devDependencies?.typescript ?? ''\")\n# Refuse rather than fall back to a floating compiler.\ntest -n \"$ts\"\nnpm install --no-audit --no-fund \"typescript@$ts\"\n# A dependency specification is not a resolved version: `^7.0.0` installs\n# whatever the registry publishes next. Compare what was installed against the\n# literal pin and refuse when they differ, so the verdict cannot move without a\n# change to the package.\ninstalled=$(node -p \"require('./node_modules/typescript/package.json').version\")\nexact=$(SPEC=\"$ts\" node -p \"const s = process.env.SPEC; s.startsWith('=') ? s.slice(1) : s\")\ntest \"$installed\" = \"$exact\"\n# Every declaration the package ships, enumerated from the installed artifact.\n# A hand-written import list cannot see a module that gains a private type\n# module later, which is the case this check exists to catch.\nfind \"node_modules/$pkg\" \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt\n# An empty list would type-check nothing and pass, which is the one way this\n# job can look healthy while checking nothing at all.\ntest -s declarations.txt\n# skipLibCheck stays at its false default: it is what makes tsc open these\n# declarations and report a reference the tarball does not carry.\nnpx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt" + "run": "set -eu\nnpm init -y > /dev/null\nnpm install --no-audit --no-fund ./*.tgz\n# The artifact installs under its own package name, which is not necessarily\n# this repository's: `fjs ci` generates workflows for other projects too. A\n# hard-coded name would fail for them, or worse, silently check a dependency\n# that happens to share the name instead of the artifact just built.\necho \"pkg=$(node -p \"Object.keys(require('./package.json').dependencies)[0]\")\" >> \"$GITHUB_ENV\"" + }, + { + "run": "set -eu\n# The compiler is the package's own pin, read out of the packed package.json:\n# with no checkout there is no lockfile, so an unpinned install would let the\n# registry change this check's verdict with no change to the repository.\nts=$(PKG=\"$pkg\" node -p \"require('./node_modules/' + process.env.PKG + '/package.json').devDependencies?.typescript ?? ''\")\n# Refuse rather than fall back to a floating compiler.\ntest -n \"$ts\"\nnpm install --no-audit --no-fund \"typescript@$ts\"\n# A dependency specification is not a resolved version: `^7.0.0` installs\n# whatever the registry publishes next. Compare what was installed against the\n# literal pin and refuse when they differ, so the verdict cannot move without a\n# change to the package.\ninstalled=$(node -p \"require('./node_modules/typescript/package.json').version\")\nexact=$(SPEC=\"$ts\" node -p \"const s = process.env.SPEC; s.startsWith('=') ? s.slice(1) : s\")\ntest \"$installed\" = \"$exact\"" + }, + { + "run": "set -eu\n# Every declaration the package ships, enumerated from the installed artifact.\n# A hand-written import list cannot see a module that gains a private type\n# module later, which is the case this check exists to catch.\nfind \"node_modules/$pkg\" \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt\n# An empty list would type-check nothing and pass, which is the one way this\n# job can look healthy while checking nothing at all.\ntest -s declarations.txt" + }, + { + "run": "set -eu\n# skipLibCheck stays at its false default: it is what makes tsc open these\n# declarations and report a reference the tarball does not carry.\nnpx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt" } ] }, diff --git a/fjs/ci/package/module.f.mjs b/fjs/ci/package/module.f.mjs index 3028fda07..d1a86b09d 100644 --- a/fjs/ci/package/module.f.mjs +++ b/fjs/ci/package/module.f.mjs @@ -19,14 +19,20 @@ export const packageCheckJobId = /** @type {const} */ ('package-check') // inherit, no `node_modules` to resolve into, and no source file that could // stand in for a declaration the tarball omits — so the job can only see what // a real consumer sees. -const script = /** @type {const} */ (`set -eu +// One step per stage, so a failure names the stage that failed instead of +// arriving as one opaque script. Shell variables do not survive between steps, +// so the two values later stages need travel through `$GITHUB_ENV`. + +const installArtifact = /** @type {const} */ (`set -eu npm init -y > /dev/null npm install --no-audit --no-fund ./*.tgz # The artifact installs under its own package name, which is not necessarily # this repository's: \`fjs ci\` generates workflows for other projects too. A # hard-coded name would fail for them, or worse, silently check a dependency # that happens to share the name instead of the artifact just built. -pkg=$(node -p "Object.keys(require('./package.json').dependencies)[0]") +echo "pkg=$(node -p "Object.keys(require('./package.json').dependencies)[0]")" >> "$GITHUB_ENV"`) + +const installPinnedCompiler = /** @type {const} */ (`set -eu # The compiler is the package's own pin, read out of the packed package.json: # with no checkout there is no lockfile, so an unpinned install would let the # registry change this check's verdict with no change to the repository. @@ -40,14 +46,18 @@ npm install --no-audit --no-fund "typescript@$ts" # change to the package. installed=$(node -p "require('./node_modules/typescript/package.json').version") exact=$(SPEC="$ts" node -p "const s = process.env.SPEC; s.startsWith('=') ? s.slice(1) : s") -test "$installed" = "$exact" +test "$installed" = "$exact"`) + +const enumerateDeclarations = /** @type {const} */ (`set -eu # Every declaration the package ships, enumerated from the installed artifact. # A hand-written import list cannot see a module that gains a private type # module later, which is the case this check exists to catch. find "node_modules/$pkg" \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt # An empty list would type-check nothing and pass, which is the one way this # job can look healthy while checking nothing at all. -test -s declarations.txt +test -s declarations.txt`) + +const typeCheck = /** @type {const} */ (`set -eu # skipLibCheck stays at its false default: it is what makes tsc open these # declarations and report a reference the tarball does not carry. npx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt`) @@ -66,6 +76,9 @@ export const packageCheckJob = { steps: [ uses('actions/download-artifact', { name: packageArtifact }), uses('actions/setup-node', { 'node-version': node.default }), - { run: script }, + { run: installArtifact }, + { run: installPinnedCompiler }, + { run: enumerateDeclarations }, + { run: typeCheck }, ], } From f6e00d22311e556881a32de2268087efbe39d376 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:08:39 +0000 Subject: [PATCH 269/370] effects: name every spread that shares the `all` ceiling Review found the module-list fan-out after the leaf one, which is the same limit at a different site: they fail independently, since a suite of many modules breaks the outer spread however few leaves each holds. The todo now lists all of them, including the two on the registration path and the two in `dev`, so the fix is scoped to the operation rather than to whichever site was reported. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/all-argument-limit.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/fjs/effects/todo/all-argument-limit.md b/fjs/effects/todo/all-argument-limit.md index d837bb57f..83ff26074 100644 --- a/fjs/effects/todo/all-argument-limit.md +++ b/fjs/effects/todo/all-argument-limit.md @@ -6,9 +6,20 @@ ### Problem `All` is declared variadic — `readonly['all', (...effects: Effect[]) => …]` — so -every fan-out reaches it as a spread. `allOk(...entries.map(one))` in -`emergent_testing/module.f.mjs` is one, `allOk(...modules.map(…))` beside it is another, -and both are how a run of any size is built. +every fan-out reaches it as a spread, and each one is a separate instance of the same +ceiling. Every site in the repository today: + +| site | what it fans out | +|-|-| +| `emergent_testing/module.f.mjs` `walkEntries` | one module's sibling leaves | +| `emergent_testing/module.f.mjs` `runModuleMap` | the modules of a run | +| `emergent_testing/module.f.mjs` `registerModule` ×2, `registerModuleMap` | the same two, for the framework-registration path | +| `emergent_testing/browser.mjs` `runBrowserProofs` | the page's module list | +| `dev/module.f.mjs` ×2 | files to load, and their imports | + +They fail independently: a suite of a hundred thousand *modules* breaks the outer spread +however few leaves each holds, and one module of a hundred thousand leaves breaks the inner +one however few modules there are. A fix has to be the operation's, not a site's. A spread is a call, and a call has an argument limit. Measured on node 22: @@ -66,7 +77,8 @@ list-shaped operation is worth having in the same change. ### Tasks - [ ] Decide the list-shaped `All` signature and whether a variadic wrapper stays. -- [ ] Move every interpreter and fixture to it in one change. +- [ ] Move every interpreter and fixture to it in one change, and every spread site in the + table above with them. - [ ] Prove a fan-out above the current ceiling — the number itself is engine-specific, so the proof asserts that a large fan-out completes rather than asserting the ceiling. From 3a2843966b0cdd67fad324c0025b5a74eb7e7698 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:10:10 +0000 Subject: [PATCH 270/370] rtti: enforce the walk's presence too, not just the pre-bound snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #1766: a length getter that deletes a declared member on its first read and restores it on its second defeats the snapshot alone. The prepass records the member present, the bound's read deletes it, the walk skips it as absent — legal under an optional schema — and the late read restores it, so snapshot and final state match. validate returned ok on ['bad'] with the member never validated, and parse built []; the data form rejected both. presenceUnchanged is now asked against both snapshots: the pre-bound one, which catches a member lost across the bound, and the walk's own, which catches one restored after the walk skipped it. Either alone leaves the other open. host.proof.mjs pins the restore variant beside the deletion; the row fails without the walk-presence check, checked in both directions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/rtti/host.proof.mjs | 39 ++++++++++++++++++++++++++++------ fjs/rtti/parse/module.f.mjs | 7 ++++-- fjs/rtti/validate/module.f.mjs | 10 ++++++--- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index a43e343ce..d33779699 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -93,6 +93,28 @@ const beyondIndexRange = () => Object.assign([1], { '4294967295': 2 }) * * @type {() => readonly Unknown[]} */ +/** + * The same, but the member comes **back** on the second `length` read — the + * variant that defeats a pre-read snapshot alone: the walk skips a member the + * value ends up carrying, so the value is accepted with a member no reader + * ever validated unless the walk's own presence is enforced too. + * + * @type {() => readonly Unknown[]} + */ +const lengthGetterRestoresAMember = () => { + const target = ['bad'] + let n = 0 + return new Proxy(target, { + get: (o, k, r) => { + if (k === 'length') { + n += 1 + if (n === 1) { delete o[0] } else if (n === 2) { o[0] = 'bad' } + } + return Reflect.get(o, k, r) + }, + }) +} + const lengthGetterDeletesAMember = () => { const target = ['bad'] let fired = false @@ -172,12 +194,17 @@ export const proof = { // read and re-asked against the final state, so the deletion is caught on // every reader instead of steering one of them into an accept. lengthGetterCannotSteerTheVerdict: () => { - /** @type {readonly Type[]} */ - const schemas = [[or(option, number)], [number]] - for (const t of schemas) { - const rv = v(t)(lengthGetterDeletesAMember()) - assertEq(rv[0], p(t)(lengthGetterDeletesAMember())[0], 'validate and parse must agree') - assertEq(rv[0], d(t)(lengthGetterDeletesAMember())[0], 'the data form must agree too') + /** @type {readonly (readonly [Type, () => readonly Unknown[]])[]} */ + const rows = [ + [[or(option, number)], lengthGetterDeletesAMember], + [[number], lengthGetterDeletesAMember], + [[or(option, number)], lengthGetterRestoresAMember], + [[number], lengthGetterRestoresAMember], + ] + for (const [t, mk] of rows) { + const rv = v(t)(mk()) + assertEq(rv[0], p(t)(mk())[0], 'validate and parse must agree') + assertEq(rv[0], d(t)(mk())[0], 'the data form must agree too') // and what they agree on: the member the schema declared was // there when it was decided, so losing it is a rejection assertError(rv) diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index b8d8d93af..58257687f 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -378,8 +378,11 @@ const constContainerParse = if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { return verror('unexpected value') } - // The pre-bound presence, as in `../validate/module.f.mjs`. - if (!presenceUnchanged(rttiEntries, presence[1], value)) { + // Both the pre-bound presence and the walk's own, as in + // `../validate/module.f.mjs` — each catches a flip the other + // does not. + if (!presenceUnchanged(rttiEntries, presence[1], value) + || !presenceUnchanged(rttiEntries, r[1].presence, value)) { return verror('unexpected value') } const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(r[1].entries)) diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index 8f0d5e5eb..0597c4376 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -266,10 +266,14 @@ const constContainerValidate = } // `value` is C (Unknown container), but Ts for T extends Tuple|Struct is not // structurally equivalent to C — TypeScript can't narrow element types through the loop. - // Against the presence decided *before* the bound was read, not - // the walk's own: that is what makes a mutating `length` getter a - // rejection rather than a steer. + // Against **both** snapshots: the one decided before the bound + // was read, and the walk's own. A `length` getter that deletes a + // declared member is caught by the first, and one that deletes it + // on its first read and restores it on its second — leaving the + // walk to skip a member the value ends up carrying unvalidated — + // is caught by the second. Either alone leaves the other open. return presenceUnchanged(rttiEntries, presence[1], value) + && presenceUnchanged(rttiEntries, r[1], value) ? /** @type {any} */ (ok(value)) : verror('unexpected value') } From 9c1ee71c5d7cd5c5703fee9c2fb6aaff340ea274 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:11:24 +0000 Subject: [PATCH 271/370] ci: install the artifact under a fixed alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplicity over the narrower case. `npm install "packed@file:$(ls *.tgz)"` installs the tarball under a fixed directory name, so every later step names it literally: the name derivation and the $GITHUB_ENV hand-off both go away, and no value crosses a step boundary any more. Nothing is baked into the generated workflow that package.json can change, so a version bump still regenerates nothing — which the derived name also achieved, but this achieves it with less machinery. What an alias gives up is recorded next to it rather than left in a review thread: a package that imports itself by name, legal once `exports` is declared, does not resolve under a different directory name, so such a package would fail a check a real consumer passes. Nothing here self-references, and the comment says to revisit if that changes. Re-verified end to end on the four emitted steps: this repository's tarball passes at 396 declarations, a tarball named other-package passes at 1 — the alias makes genericity structural rather than derived — and failures still attribute per step, a range pin at the compiler step and a dangling declaration at the type-check step with TS2307. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 6 +++--- fjs/ci/package/module.f.mjs | 23 +++++++++++++---------- fjs/ci/package/proof.f.mjs | 15 +++++++-------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4aae9aa09..331c55c32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -539,13 +539,13 @@ } }, { - "run": "set -eu\nnpm init -y > /dev/null\nnpm install --no-audit --no-fund ./*.tgz\n# The artifact installs under its own package name, which is not necessarily\n# this repository's: `fjs ci` generates workflows for other projects too. A\n# hard-coded name would fail for them, or worse, silently check a dependency\n# that happens to share the name instead of the artifact just built.\necho \"pkg=$(node -p \"Object.keys(require('./package.json').dependencies)[0]\")\" >> \"$GITHUB_ENV\"" + "run": "set -eu\nnpm init -y > /dev/null\nnpm install --no-audit --no-fund \"packed@file:$(ls *.tgz)\"" }, { - "run": "set -eu\n# The compiler is the package's own pin, read out of the packed package.json:\n# with no checkout there is no lockfile, so an unpinned install would let the\n# registry change this check's verdict with no change to the repository.\nts=$(PKG=\"$pkg\" node -p \"require('./node_modules/' + process.env.PKG + '/package.json').devDependencies?.typescript ?? ''\")\n# Refuse rather than fall back to a floating compiler.\ntest -n \"$ts\"\nnpm install --no-audit --no-fund \"typescript@$ts\"\n# A dependency specification is not a resolved version: `^7.0.0` installs\n# whatever the registry publishes next. Compare what was installed against the\n# literal pin and refuse when they differ, so the verdict cannot move without a\n# change to the package.\ninstalled=$(node -p \"require('./node_modules/typescript/package.json').version\")\nexact=$(SPEC=\"$ts\" node -p \"const s = process.env.SPEC; s.startsWith('=') ? s.slice(1) : s\")\ntest \"$installed\" = \"$exact\"" + "run": "set -eu\n# The compiler is the package's own pin, read out of the packed package.json:\n# with no checkout there is no lockfile, so an unpinned install would let the\n# registry change this check's verdict with no change to the repository.\nts=$(node -p \"require('./node_modules/packed/package.json').devDependencies?.typescript ?? ''\")\n# Refuse rather than fall back to a floating compiler.\ntest -n \"$ts\"\nnpm install --no-audit --no-fund \"typescript@$ts\"\n# A dependency specification is not a resolved version: `^7.0.0` installs\n# whatever the registry publishes next. Compare what was installed against the\n# literal pin and refuse when they differ, so the verdict cannot move without a\n# change to the package.\ninstalled=$(node -p \"require('./node_modules/typescript/package.json').version\")\nexact=$(SPEC=\"$ts\" node -p \"const s = process.env.SPEC; s.startsWith('=') ? s.slice(1) : s\")\ntest \"$installed\" = \"$exact\"" }, { - "run": "set -eu\n# Every declaration the package ships, enumerated from the installed artifact.\n# A hand-written import list cannot see a module that gains a private type\n# module later, which is the case this check exists to catch.\nfind \"node_modules/$pkg\" \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt\n# An empty list would type-check nothing and pass, which is the one way this\n# job can look healthy while checking nothing at all.\ntest -s declarations.txt" + "run": "set -eu\n# Every declaration the package ships, enumerated from the installed artifact.\n# A hand-written import list cannot see a module that gains a private type\n# module later, which is the case this check exists to catch.\nfind node_modules/packed \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt\n# An empty list would type-check nothing and pass, which is the one way this\n# job can look healthy while checking nothing at all.\ntest -s declarations.txt" }, { "run": "set -eu\n# skipLibCheck stays at its false default: it is what makes tsc open these\n# declarations and report a reference the tarball does not carry.\nnpx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt" diff --git a/fjs/ci/package/module.f.mjs b/fjs/ci/package/module.f.mjs index d1a86b09d..6b15ccb6e 100644 --- a/fjs/ci/package/module.f.mjs +++ b/fjs/ci/package/module.f.mjs @@ -20,23 +20,26 @@ export const packageCheckJobId = /** @type {const} */ ('package-check') // stand in for a declaration the tarball omits — so the job can only see what // a real consumer sees. // One step per stage, so a failure names the stage that failed instead of -// arriving as one opaque script. Shell variables do not survive between steps, -// so the two values later stages need travel through `$GITHUB_ENV`. +// arriving as one opaque script. + +// A fixed alias, so every later step names the package literally. The +// artifact's own name would otherwise have to be derived and carried between +// steps, and `fjs ci` generates workflows for projects whose package is not +// this one. The narrow case an alias gives up: a package that imports itself by +// name — legal once `exports` is declared — does not resolve under a different +// directory name, so such a package would fail a check a real consumer passes. +// Nothing here self-references; revisit this if that changes. +const alias = /** @type {const} */ ('packed') const installArtifact = /** @type {const} */ (`set -eu npm init -y > /dev/null -npm install --no-audit --no-fund ./*.tgz -# The artifact installs under its own package name, which is not necessarily -# this repository's: \`fjs ci\` generates workflows for other projects too. A -# hard-coded name would fail for them, or worse, silently check a dependency -# that happens to share the name instead of the artifact just built. -echo "pkg=$(node -p "Object.keys(require('./package.json').dependencies)[0]")" >> "$GITHUB_ENV"`) +npm install --no-audit --no-fund "${alias}@file:$(ls *.tgz)"`) const installPinnedCompiler = /** @type {const} */ (`set -eu # The compiler is the package's own pin, read out of the packed package.json: # with no checkout there is no lockfile, so an unpinned install would let the # registry change this check's verdict with no change to the repository. -ts=$(PKG="$pkg" node -p "require('./node_modules/' + process.env.PKG + '/package.json').devDependencies?.typescript ?? ''") +ts=$(node -p "require('./node_modules/${alias}/package.json').devDependencies?.typescript ?? ''") # Refuse rather than fall back to a floating compiler. test -n "$ts" npm install --no-audit --no-fund "typescript@$ts" @@ -52,7 +55,7 @@ const enumerateDeclarations = /** @type {const} */ (`set -eu # Every declaration the package ships, enumerated from the installed artifact. # A hand-written import list cannot see a module that gains a private type # module later, which is the case this check exists to catch. -find "node_modules/$pkg" \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt +find node_modules/${alias} \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt # An empty list would type-check nothing and pass, which is the one way this # job can look healthy while checking nothing at all. test -s declarations.txt`) diff --git a/fjs/ci/package/proof.f.mjs b/fjs/ci/package/proof.f.mjs index 6e974dfdf..da24d2e3c 100644 --- a/fjs/ci/package/proof.f.mjs +++ b/fjs/ci/package/proof.f.mjs @@ -39,15 +39,14 @@ export const proof = { // not match the pin means the registry, not the package, decided. assert(scriptHas('test "$installed" = "$exact"'), 'expected the installed compiler matched against the pin') }, - // `fjs ci` generates workflows for other projects, so the artifact's - // package name is whatever that project publishes. A hard-coded name would - // fail for them — or worse, silently check a dependency that happens to - // share the name instead of the artifact just built. + // `fjs ci` generates workflows for other projects, so the artifact's own + // package name is whatever that project publishes. Installing under a fixed + // alias keeps every later step literal; hard-coding this repository's name + // instead would fail for them — or worse, silently check a dependency that + // happens to share the name instead of the artifact just built. anyPackageName: () => { - assert( - scriptHas("Object.keys(require('./package.json').dependencies)[0]"), - 'expected the package directory derived from the artifact') - assert(scriptHas('find "node_modules/$pkg"'), 'expected declarations enumerated from that directory') + assert(scriptHas('"packed@file:$(ls *.tgz)"'), 'expected the artifact installed under the fixed alias') + assert(scriptHas('find node_modules/packed'), 'expected declarations enumerated from that directory') assert( !scriptHas('node_modules/functionalscript'), 'the package check must not hard-code this repository\'s package name') From 73bb49e4945c161f718504d547ad9cd0fd3642b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:15:34 +0000 Subject: [PATCH 272/370] fjs: `@module` is what publishes a module doc, not an entry-point marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §2 said the tag belongs only to `module.f.mjs` / `module.mjs`. That is the rule #1756 enforced across 102 files, and it had the reason backwards. `deno doc` reads the tag and nothing else. A file whose leading block carries it gets that prose as `module_doc`; a file without it gets no `module_doc` at all — the block is dropped, not demoted. Checked against the Deno the repository pins, for `.mjs` and `.ts` alike, and the tag need not be in the first block, only in some block. So the tag is what makes a leading block *be* documentation, and it goes wherever a file has module documentation to publish: `module.f.mjs`, `types.ts`, `private.ts`. A block holding only `@import` tags has nothing to attach and wants none. Where the documentation is never published the tag buys nothing, which is `proof.*`. 98 of the 102 stripped files had real prose, so their module documentation is currently invisible to `deno doc` — the output `fjs/website/todo/publish-deno-doc-to-website.md` plans to publish. §2 says so plainly rather than describing a tree that does not exist, and points at the restore. `fjs/todo/module-tag-restore.md` (P3) carries the inventory, split by the three decisions it actually contains: 89 `types.ts` with prose, mechanical; 11 proof files where the rule says leave them but that is worth confirming; and two files that are neither — `browser.mjs` has real prose and reads like the first group, `testlib.f.mjs` held only `@import` tags and wants no tag at all. `todo/jsdoc-verification.md` (P4) asks how any of this could be checked. Three passes over one tag with no signal at any point is the argument for looking; §6 ruling out text patterns is why the answer has to parse, and "not worth it, leave it to review" is a legitimate outcome to record. Documentation only. `npx tsc` clean, suite 3533/3533. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- fjs/AGENTS.md | 28 ++++++++++--- fjs/todo/module-tag-restore.md | 68 +++++++++++++++++++++++++++++++ todo/jsdoc-verification.md | 73 ++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 fjs/todo/module-tag-restore.md create mode 100644 todo/jsdoc-verification.md diff --git a/fjs/AGENTS.md b/fjs/AGENTS.md index e43afc9fe..0c3996346 100644 --- a/fjs/AGENTS.md +++ b/fjs/AGENTS.md @@ -132,12 +132,28 @@ 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. + +**`@module` is what makes a leading block *be* module documentation.** It is not +a marker of entry-point-ness. `deno doc` reads the tag and nothing else: a file +whose leading block carries it gets that prose as its `module_doc`, and a file +without it gets no `module_doc` at all — the block is dropped, not demoted. +Verified against the pinned Deno (`fjs/ci/config/module.f.mjs`), for `.mjs` and +`.ts` alike; the tag need not be in the first block, only in some block. + +**So the tag goes wherever a file has module-level documentation to publish** — +`module.f.mjs`, `types.ts`, `private.ts` — and a `module.*` file always has some. +A file whose leading block only holds `@import` tags has nothing to attach and +wants no `@module`. Where a file's documentation is never published, the tag buys +nothing; `proof.*` is the clear case. + +Put it in the leading block, followed by one blank line before the first +source-level import or declaration. + +The tree does not obey this yet. #1756 stripped the tag from 102 files on the +older reading, 98 of which had real prose, so their module documentation is +currently invisible to `deno doc`. Restoring it is +[`fjs/todo/module-tag-restore.md`](./todo/module-tag-restore.md); until that +lands, a `types.ts` without the tag is debt rather than an example to copy. 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 diff --git a/fjs/todo/module-tag-restore.md b/fjs/todo/module-tag-restore.md new file mode 100644 index 000000000..38ade118b --- /dev/null +++ b/fjs/todo/module-tag-restore.md @@ -0,0 +1,68 @@ +## module-tag-restore. Put `@module` back where documentation is published + +**Priority:** P3 +**Status:** open + +### Problem + +[#1756](https://github.com/functionalscript/functionalscript/pull/1756) stripped +`@module` from 102 files, on the reading that the tag marks a package entry +point. That reading was wrong. The tag is what makes a leading JSDoc block *be* +module documentation: `deno doc` emits `module_doc` for a file carrying it and +nothing at all for a file without it — the block is dropped, not demoted. +Verified against the pinned Deno, for `.mjs` and `.ts` alike. + +98 of those 102 files had real prose in the block. Their module documentation is +still in the source and is now invisible to `deno doc`, which +[`../website/todo/publish-deno-doc-to-website.md`](../website/todo/publish-deno-doc-to-website.md) +plans to publish. Nothing is broken at runtime; what is lost is the description +of 89 type-level APIs in generated documentation. + +[`../AGENTS.md`](../AGENTS.md) §2 now states the rule correctly — the tag goes +wherever a file has module-level documentation to publish. This issue is the +tree catching up. + +### Proposal + +Three groups, and only the first is mechanical. + +**1. Restore — 89 `types.ts`, all with prose.** Put `@module` back in the +leading block. `types.ts` is the entry point of the type-level API and its +emitted declarations are what a package consumer reads, so this is squarely +"documentation to publish". The exact text is recoverable per file: + +```sh +git show 0233904^: +``` + +**2. Decide — 11 proof files** (8 with prose, 3 with a bare tag). Proof +documentation is not published, so by the rule the tag buys nothing and they +should stay as they are. Worth confirming rather than assuming: if `deno doc` +is ever pointed at proofs, or a reader is expected to browse them, the answer +flips. The three bare-tag ones lost nothing either way. + +**3. Judge individually — two files that are neither.** + +- `fjs/bnf/testlib.f.mjs` — its block held only `@import` tags, no prose. Under + the rule there is nothing to attach, so it wants no tag. Nothing to restore. +- `fjs/emergent_testing/browser.mjs` — real prose ("Browser-native proof + execution and report rendering", and why it has no Node dependencies). It is + a published module in the package, so it reads like group 1. + +### Tasks + +- [ ] Restore the tag in the 89 `types.ts` files. +- [ ] Restore `fjs/emergent_testing/browser.mjs`; leave `fjs/bnf/testlib.f.mjs`. +- [ ] Decide the proof files, and record the decision in + [`../AGENTS.md`](../AGENTS.md) §2 rather than only here. +- [ ] Drop §2's paragraph saying the tree does not obey the rule yet. +- [ ] Spot-check with `deno doc --json` on a restored file that `module_doc` + comes back, rather than trusting the edit. + +### Related + +- [`../AGENTS.md`](../AGENTS.md) §2 — the rule, and why the tag exists. +- [`../website/todo/publish-deno-doc-to-website.md`](../website/todo/publish-deno-doc-to-website.md) + — what makes this visible rather than theoretical. +- [`../../todo/jsdoc-verification.md`](../../todo/jsdoc-verification.md) — how a + rule like this might be checked at all, which is why it drifted twice unnoticed. diff --git a/todo/jsdoc-verification.md b/todo/jsdoc-verification.md new file mode 100644 index 000000000..2547b1044 --- /dev/null +++ b/todo/jsdoc-verification.md @@ -0,0 +1,73 @@ +## jsdoc-verification. Investigate how JSDoc correctness could be checked + +**Priority:** P4 +**Status:** open + +### Problem + +Several JSDoc rules are documented and none are checkable. `fjs/AGENTS.md` §2 +states where `@module` goes, how `@import` tags are grouped, and what a leading +block must contain; the root `AGENTS.md` prohibits file-scope `@typedef` in +authored `.mjs`. `tsc` sees none of it — a tag in the wrong place, missing, or +absent from a file that needs one all type-check clean. + +The consequences are not hypothetical. `@module` drifted onto 102 files against +the documented rule, was stripped from all of them on a misreading of *why* the +rule existed, and the misreading survived a merge because nothing could tell the +difference. That is three passes over the same tag with no signal at any point. + +The obvious repair is not available. Root [`AGENTS.md` +§6](../AGENTS.md#6-external-tools) rules out approximating this with a text +pattern, and for a good reason discovered the hard way: a `grep` for `@module` +cannot distinguish a JSDoc tag from the same characters in a string or a +comment, and the guard built that way flagged the file whose assertions named +it. A checker has to parse. + +### Proposal + +An investigation, not a design. What to establish: + +- **What already parses this.** `deno doc --json` yields `module_doc` and + per-symbol `jsDoc` — enough to answer "does this file publish module + documentation", which is most of the `@module` rule, with no new dependency + and a tool the repository already pins. Whether it can see tag *placement* + and `@import` grouping is the open question. +- **ESLint**, named in §6 as the kind of tool this wants. `eslint-plugin-jsdoc` + covers tag presence and shape; whether it can express repository-specific + rules (this tag in this file kind) without custom rules of our own is what to + find out. Adding it needs approval per §6, and + [`../todo/eslint.md`](./eslint.md) already holds that discussion — check it + before opening a second one. +- **The TypeScript compiler API**, which already parses every file `tsc` reads + and exposes JSDoc nodes. No new tool to approve, but it means writing a + checker, which is the cost §6 warns about. + +Then decide, per rule, whether it is worth checking at all. §6's position is +that an unenforced written rule beats machinery whose failures are silent, so +"none of these are good enough, leave the rules to review" is a legitimate +outcome of this investigation and should be recorded as one rather than left +open. + +**P4 because reviewers do catch these.** Both `@module` reversals were found by +review — one by a human, one by a bot — before either reached a release. This +is worth doing when a tool makes it cheap, not worth building a tool for. + +### Tasks + +- [ ] Establish what `deno doc --json` can and cannot answer about tag + placement. +- [ ] Read [`eslint.md`](./eslint.md) and fold this in rather than duplicating + it, if the answer is ESLint. +- [ ] Pick one rule as the trial — `@module` presence is the narrowest — and + say what checking it would cost. +- [ ] Decide, and record "not worth it" as an answer if that is the answer. + +### Related + +- [`../AGENTS.md`](../AGENTS.md#6-external-tools) §6 — why not a text pattern, + and that a real tool needs approval first. +- [`../fjs/AGENTS.md`](../fjs/AGENTS.md) §2 — the `@module` and `@import` rules + this would check. +- [`../fjs/todo/module-tag-restore.md`](../fjs/todo/module-tag-restore.md) — the + drift this issue exists because of. +- [`eslint.md`](./eslint.md) — the standing ESLint discussion. From ac074276c55979585fa2732fb464a137b0c70ef1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:15:59 +0000 Subject: [PATCH 273/370] effects/browser: prove the charging by ordering, not by coincidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reportingIsChargedToTheBudget` watched for a macrotask turn during a run. Alone that means something; under the whole suite another proof hands the loop a boundary, so it passed with the defect present — green cover for a regression, which is worse than no proof at all. Its replacement races a dispatch against a chain of microtasks. A macrotask cannot run until every pending microtask has, so which one wins says what kind of boundary the operation waited for, and says it the same way however busy the process is. Under a runner that charges only `sandbox` it fails 5 times out of 5 in isolation and takes the full `npm test` with it; restored, it passes 5 out of 5. The todo said the budget lives in `sandbox` in four places, written one commit before the budget moved to every operation. It says what the code does now, and records the inert proof as the third mistake in this sequence. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/browser/proof.mjs | 25 +++++++++ fjs/emergent_testing/browser/proof.mjs | 24 --------- .../todo/share-browser-console-runner.md | 54 +++++++++++++------ 3 files changed, 63 insertions(+), 40 deletions(-) diff --git a/fjs/effects/browser/proof.mjs b/fjs/effects/browser/proof.mjs index 244260b4c..677709393 100644 --- a/fjs/effects/browser/proof.mjs +++ b/fjs/effects/browser/proof.mjs @@ -88,6 +88,31 @@ export const proof = { assertEq(r.length, 3) assert(deliveredDuringRun) }, + // **Every operation is charged, not only the leaf.** A page whose proofs are + // trivial and whose reporting paints a row spends its time in the operation + // it added, so a budget that watched `sandbox` alone would let a hundred + // cheap leaves start in one slice and then drain a hundred paints. + // + // Asserted by ordering rather than by observing a turn: a macrotask cannot + // run until every pending microtask has, so racing the dispatch against a + // long chain of microtasks says which kind of boundary it waited for, and + // says it the same way however busy the process is. A proof that watched + // for *a* turn instead would pass whenever anything else in the suite + // happened to yield nearby — green with the defect present, which is worse + // than no proof. + everyOperationIsChargedToTheBudget: async () => { + const run = browserRun(/** @type {any} */ ({ mark: async () => ok('marked') })) + // Spend the slice before dispatching, so the budget is owed. + const end = performance.now() + 25 + while (performance.now() < end) { /* hold the thread */ } + const dispatched = run(/** @type {any} */ (do_('mark'))()).then(() => 'operation') + const microtasks = (async () => { + for (let i = 0; i < 200; i += 1) { await null } + return 'microtasks' + })() + assertEq(await Promise.race([dispatched, microtasks]), 'microtasks') + assertEq(okValue(await dispatched.then(() => run(/** @type {any} */ (do_('mark'))()))), 'marked') + }, // Enumerating `extra` runs user code too: a proxy may answer one set of // keys and then another. Reading it once means the map the runner builds is // the map the collision check approved — here the second reading's diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index 1a2901160..b749150b5 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -354,30 +354,6 @@ export const proof = { assertStructurallySame([...p.states], ['running', 'failed']) assertEq(p.view.events.length, 1) }, - // **Reporting is work on the page's thread too.** A hundred trivial leaves - // fit inside one slice, so a budget that watched only the leaf would let - // them all start and then drain a hundred renders with no turn given back. - // The message queued before the run is the page's stand-in for a paint: it - // has to arrive while the run is still going. - reportingIsChargedToTheBudget: async () => { - const proof = Object.fromEntries( - Array.from({ length: 100 }, (_, index) => [`t${index}`, () => undefined])) - let deliveredDuringRun = false - let finished = false - const { port1, port2 } = new MessageChannel() - port1.onmessage = () => { deliveredDuringRun = !finished } - port2.postMessage(undefined) - const report = await runBrowserProofs([['m', proof]], () => { - // A renderer that costs something, as appending a row does. - const end = performance.now() + 1 - while (performance.now() < end) { /* paint the row */ } - }) - finished = true - port1.close() - port2.close() - assertEq(report.totals.passed, 100) - assert(deliveredDuringRun) - }, // A parent precedes the children its return value produced, however deep // the chain goes — the records are joined as a rope and walked out once, // so nesting must not reorder them the way a per-level rebuild could. diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 7bc3df94c..fafaad024 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -203,9 +203,10 @@ and is reviewable without the next one. own", and that was half right in a way worth keeping. The *traversal* has none, which is what the whole issue is about, and nothing about a batch of proofs belongs here. But an interpreter for a host with a UI thread must - give that thread back, or the host cannot paint — so `sandbox` carries one - policy, a frame budget, which is a statement about the browser and not - about proofs. The Tasks list below records what that cost to learn. + give that thread back, or the host cannot paint — so it carries one + policy, a frame budget charged to every operation it dispatches, which is + a statement about the browser and not about proofs. The Tasks list below + records what that cost to learn. - [x] **6. One reporter.** The event stream — a leaf landed, a run ended — that both hosts subscribe to. Step 2 gave them the *value*; this gave them the seam it travels through. `Reporter.result` now receives the @@ -424,9 +425,11 @@ differ in cost by orders of magnitude. That is what happened, and this paragraph turned out to be right on every count. The problem was reported — a page frozen for the length of a run, with the browser offering to kill it — the change was measured in a real browser -before and after, and the boundary is per leaf on a frame budget rather than -per N proofs. It lives in the interpreter, where a statement about a host -belongs, and the traversal still schedules nothing. +before and after, and the boundary is per unit of work on a frame budget rather +than per N proofs — every operation the interpreter dispatches, since rendering +a result is work on the same thread as running a proof. It lives in the +interpreter, where a statement about a host belongs, and the traversal still +schedules nothing. An executor boundary will still be necessary because the console runner uses the Effects sandbox while a browser catches synchronous throws and awaits @@ -545,11 +548,12 @@ are shared. - [ ] Close each of those issues for both runners at once, so the two stay in sync rather than drifting from the day the core is shared. - [x] Decide where a browser run gives the thread back. **The browser - interpreter's `sandbox`, on a frame budget** — 8 ms, what a 60 Hz frame - leaves for script — not a count of proofs, and not the traversal, which - stays free of scheduling so `fjs t` is untouched. + interpreter, on a frame budget charged to every operation it dispatches** + — 8 ms, what a 60 Hz frame leaves for script — not a count of proofs, and + not the traversal, which stays free of scheduling so `fjs t` is + untouched. - This was got wrong twice before it was measured, and both errors are + This was got wrong three times before it was measured, and all three are worth keeping. First, deleting `batchSize = 25` was read as deleting the whole idea: the constant was indefensible — twenty-five trivial leaves are nothing and twenty-five heavy ones are still a freeze — but the @@ -569,12 +573,30 @@ are shared. awaiting when there is room, which is why it answers `null` rather than a settled promise. - After: longest task **98 ms** on the first run and **no task over 50 ms** - on the second, 3456 rows painted, wall clock 52.2 s against 52.8 s — the - yields cost 0.38 ms each and the budget asks for few of them. `all` was - not the place to put this: it must start every child before awaiting any, - so pausing between children hangs a graph whose child waits on a later - sibling, which is the deadlock the reverted attempt hit. + Third — and the proof for it was wrong before the code was — only + `sandbox` was charged, which holds until a page is cheap to test and + expensive to render: a hundred trivial leaves start inside one + slice, and the hundred renders that follow drain with no turn given back. + Whatever the runner dispatches runs on the host's thread, so every + operation is charged now — the ones a host adds included, because that is + the host's own work. + + The first proof for that watched for *a* turn during a run, and was inert + where the project runs it: under the whole suite another proof hands the + loop a boundary, so the check passed with the defect present. Its + replacement asserts by ordering instead — a macrotask cannot run until + every pending microtask has, so racing the dispatch against a chain of + microtasks says which kind of boundary it waited for, whatever else the + process is doing. **A proof that observes a coincidence is worse than no + proof**, because it is counted as cover. + + After: longest task **97–104 ms**, none of the 3,461 rows waiting for the + end, wall clock 50.9 s against 52.8 s — the yields cost 0.38 ms each and + the budget asks for few of them. What is left blocking is a single proof + that computes without stopping, which nothing at this layer can split. + `all` was not the place to put this: it must start every child before + awaiting any, so pausing between children hangs a graph whose child waits + on a later sibling, which is the deadlock the reverted attempt hit. - [ ] Prove `runBrowserProofs`'s `infrastructure-error` branch — the run's own failure, as opposed to any proof's. It is the one branch of the page with no proof, and reaching either half of it (an operation reporting through From 26a6c2fb78d4a0836925528704c59fd934d16b74 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:22:00 +0000 Subject: [PATCH 274/370] ci: pin the compiler in config, and drop the shell that was doing logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The steps were unreadable because they did work that belongs in the generator. Thirty lines of run: script become eight. set -eu is gone. GitHub runs run: steps as `bash -e`, so -e was already there, and -u guarded unset variables only while a value crossed steps through $GITHUB_ENV; nothing crosses now. Verified by running the emitted steps under `bash -e`: failures still propagate and still land in the right step. The compiler version moves to fjs/ci/config, beside the bun, deno, node and wasmtime pins. That deletes the whole of the second step: reading the pin out of the packed package.json at run time, installing it, re-reading what was actually installed, and stripping a leading `=` through an environment variable to compare the two. All of it existed to establish a version this repository already states, and now states in the one place tool versions live. One line remains. This changes what the pin means, which is worth saying plainly: the check now installs the version this repository pins rather than the one the artifact declares. For a package generated by `fjs ci` elsewhere that is the CI tool's compiler, exactly as the node, deno and bun versions in the same file already are. It also removes the range hole by construction — a config constant cannot be a floating range without that being a visible change here. Also enumerates *.d.cts, so "every declaration the package ships" is true for a package that ships CommonJS declarations, and requires exactly one archive, so which package is under test is never ambiguous. Verified end to end on the emitted steps: 396 declarations and TypeScript 7.0.2 for this package, a .d.cts-only package enumerated and checked, two archives refused, and a dangling declaration still failing at the type-check step with TS2307. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 8 +++---- fjs/ci/config/module.f.mjs | 7 ++++++ fjs/ci/package/module.f.mjs | 46 ++++++++++++------------------------- fjs/ci/package/proof.f.mjs | 16 ++++++++++--- 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 331c55c32..45186fd22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -539,16 +539,16 @@ } }, { - "run": "set -eu\nnpm init -y > /dev/null\nnpm install --no-audit --no-fund \"packed@file:$(ls *.tgz)\"" + "run": "npm init -y > /dev/null\n# One archive, or which package is under test is ambiguous.\ntest \"$(ls *.tgz | wc -l)\" -eq 1\nnpm install \"packed@file:$(ls *.tgz)\"" }, { - "run": "set -eu\n# The compiler is the package's own pin, read out of the packed package.json:\n# with no checkout there is no lockfile, so an unpinned install would let the\n# registry change this check's verdict with no change to the repository.\nts=$(node -p \"require('./node_modules/packed/package.json').devDependencies?.typescript ?? ''\")\n# Refuse rather than fall back to a floating compiler.\ntest -n \"$ts\"\nnpm install --no-audit --no-fund \"typescript@$ts\"\n# A dependency specification is not a resolved version: `^7.0.0` installs\n# whatever the registry publishes next. Compare what was installed against the\n# literal pin and refuse when they differ, so the verdict cannot move without a\n# change to the package.\ninstalled=$(node -p \"require('./node_modules/typescript/package.json').version\")\nexact=$(SPEC=\"$ts\" node -p \"const s = process.env.SPEC; s.startsWith('=') ? s.slice(1) : s\")\ntest \"$installed\" = \"$exact\"" + "run": "npm install \"typescript@=7.0.2\"" }, { - "run": "set -eu\n# Every declaration the package ships, enumerated from the installed artifact.\n# A hand-written import list cannot see a module that gains a private type\n# module later, which is the case this check exists to catch.\nfind node_modules/packed \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt\n# An empty list would type-check nothing and pass, which is the one way this\n# job can look healthy while checking nothing at all.\ntest -s declarations.txt" + "run": "find node_modules/packed \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) > declarations.txt\ntest -s declarations.txt" }, { - "run": "set -eu\n# skipLibCheck stays at its false default: it is what makes tsc open these\n# declarations and report a reference the tarball does not carry.\nnpx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt" + "run": "npx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt" } ] }, diff --git a/fjs/ci/config/module.f.mjs b/fjs/ci/config/module.f.mjs index 8bed9ba7c..4bccb956e 100644 --- a/fjs/ci/config/module.f.mjs +++ b/fjs/ci/config/module.f.mjs @@ -33,6 +33,13 @@ export const bun = '1.4.0' // https://deno.com/ export const deno = '2.9.6' +// The compiler the packed-package check installs. Kept here with the other tool +// pins rather than read out of the artifact at run time: with no checkout that +// job has no lockfile, so the version has to come from somewhere version +// controlled or the registry decides the check's verdict. +// https://www.npmjs.com/package/typescript +export const typescript = '=7.0.2' + // The Node versions the pinned Nixpkgs snapshot below provides — read from // `pkgs/development/web/nodejs/v{22,24,26}.nix` at that commit. Every runtime // uses these: `setup-node` on the GitHub-hosted runners and the generated diff --git a/fjs/ci/package/module.f.mjs b/fjs/ci/package/module.f.mjs index 6b15ccb6e..c5a0801f9 100644 --- a/fjs/ci/package/module.f.mjs +++ b/fjs/ci/package/module.f.mjs @@ -7,7 +7,7 @@ * @import { Job } from '../common/types.ts' */ -import { images, node } from '../config/module.f.mjs' +import { images, node, typescript } from '../config/module.f.mjs' import { uses } from '../common/module.f.mjs' import { packageArtifact, packageJobId } from '../node/module.f.mjs' @@ -31,39 +31,23 @@ export const packageCheckJobId = /** @type {const} */ ('package-check') // Nothing here self-references; revisit this if that changes. const alias = /** @type {const} */ ('packed') -const installArtifact = /** @type {const} */ (`set -eu -npm init -y > /dev/null -npm install --no-audit --no-fund "${alias}@file:$(ls *.tgz)"`) +const installArtifact = /** @type {const} */ (`npm init -y > /dev/null +# One archive, or which package is under test is ambiguous. +test "$(ls *.tgz | wc -l)" -eq 1 +npm install "${alias}@file:$(ls *.tgz)"`) -const installPinnedCompiler = /** @type {const} */ (`set -eu -# The compiler is the package's own pin, read out of the packed package.json: -# with no checkout there is no lockfile, so an unpinned install would let the -# registry change this check's verdict with no change to the repository. -ts=$(node -p "require('./node_modules/${alias}/package.json').devDependencies?.typescript ?? ''") -# Refuse rather than fall back to a floating compiler. -test -n "$ts" -npm install --no-audit --no-fund "typescript@$ts" -# A dependency specification is not a resolved version: \`^7.0.0\` installs -# whatever the registry publishes next. Compare what was installed against the -# literal pin and refuse when they differ, so the verdict cannot move without a -# change to the package. -installed=$(node -p "require('./node_modules/typescript/package.json').version") -exact=$(SPEC="$ts" node -p "const s = process.env.SPEC; s.startsWith('=') ? s.slice(1) : s") -test "$installed" = "$exact"`) +const installCompiler = /** @type {const} */ (`npm install "typescript@${typescript}"`) -const enumerateDeclarations = /** @type {const} */ (`set -eu -# Every declaration the package ships, enumerated from the installed artifact. -# A hand-written import list cannot see a module that gains a private type -# module later, which is the case this check exists to catch. -find node_modules/${alias} \\( -name '*.d.ts' -o -name '*.d.mts' \\) > declarations.txt -# An empty list would type-check nothing and pass, which is the one way this -# job can look healthy while checking nothing at all. +// Every declaration the package ships, enumerated from the installed artifact: +// a hand-written import list cannot see a module that gains a private type +// module later, which is the case this check exists to catch. An empty list +// would type-check nothing and pass. +const enumerateDeclarations = /** @type {const} */ (`find node_modules/${alias} \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) > declarations.txt test -s declarations.txt`) -const typeCheck = /** @type {const} */ (`set -eu -# skipLibCheck stays at its false default: it is what makes tsc open these -# declarations and report a reference the tarball does not carry. -npx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt`) +// skipLibCheck stays at its false default: it is what makes tsc open these +// declarations and report a reference the tarball does not carry. +const typeCheck = /** @type {const} */ (`npx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt`) /** * Downloads the packed tarball, installs it as a real dependency, and @@ -80,7 +64,7 @@ export const packageCheckJob = { uses('actions/download-artifact', { name: packageArtifact }), uses('actions/setup-node', { 'node-version': node.default }), { run: installArtifact }, - { run: installPinnedCompiler }, + { run: installCompiler }, { run: enumerateDeclarations }, { run: typeCheck }, ], diff --git a/fjs/ci/package/proof.f.mjs b/fjs/ci/package/proof.f.mjs index da24d2e3c..1274841a5 100644 --- a/fjs/ci/package/proof.f.mjs +++ b/fjs/ci/package/proof.f.mjs @@ -1,5 +1,6 @@ import { packageCheckJob, packageCheckJobId } from './module.f.mjs' import { packageArtifact, packageJobId } from '../node/module.f.mjs' +import { typescript } from '../config/module.f.mjs' import { assert, assertEq } from '../../asserts/module.f.mjs' /** @type {(fragment: string) => boolean} */ @@ -35,9 +36,10 @@ export const proof = { assert(scriptHas('--skipLibCheck false'), 'expected skipLibCheck left false') // An empty list type-checks nothing and passes. assert(scriptHas('test -s declarations.txt'), 'expected a guard against an empty file list') - // A range is not a resolved version, so an installed compiler that does - // not match the pin means the registry, not the package, decided. - assert(scriptHas('test "$installed" = "$exact"'), 'expected the installed compiler matched against the pin') + // With no checkout there is no lockfile, so the compiler comes from the + // repository's pin. An unpinned install would let the registry change + // this check's verdict with no change here. + assert(scriptHas(`"typescript@${typescript}"`), 'expected the pinned compiler') }, // `fjs ci` generates workflows for other projects, so the artifact's own // package name is whatever that project publishes. Installing under a fixed @@ -46,7 +48,15 @@ export const proof = { // happens to share the name instead of the artifact just built. anyPackageName: () => { assert(scriptHas('"packed@file:$(ls *.tgz)"'), 'expected the artifact installed under the fixed alias') + // More than one archive leaves it ambiguous which package is under test. + assert(scriptHas('test "$(ls *.tgz | wc -l)" -eq 1'), 'expected exactly one archive required') assert(scriptHas('find node_modules/packed'), 'expected declarations enumerated from that directory') + // Every declaration form the package can ship, not just the two this + // repository happens to emit — `fjs ci` generates for projects whose + // `files` may include CommonJS declarations. + for (const ext of /** @type {const} */ (['*.d.ts', '*.d.mts', '*.d.cts'])) { + assert(scriptHas(`-name '${ext}'`), `expected ${ext} enumerated`) + } assert( !scriptHas('node_modules/functionalscript'), 'the package check must not hard-code this repository\'s package name') From b7d8e2da26bb3a8d2469ca73782327fa8206216b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:22:10 +0000 Subject: [PATCH 275/370] fjs: reconcile the proof example, add `private.ts`, drop an overstated claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all against text I wrote in the previous commit. §1.2 still showed a `proof.f.mjs` leading block carrying `@module`, while the §2 I had just rewritten said a proof's documentation is never published so the tag has nothing to attach to. Same file, opposite instructions for the same shape. The example loses the tag and says why. The restore issue omitted `private.ts`. All 16 have prose and none carry the tag — stripped by #1750 rather than #1756, so they were not in the 102 I counted, but it is the same reading and the same fix, and §2 names `private.ts` explicitly. As written the issue could have been completed in full while leaving 16 files violating the rule it exists to satisfy, and its last task says to delete §2's non-compliance note. And the publication rationale was overstated. The website issue plans `deno doc --html **/module.f.mjs`, a glob excluding every `types.ts` and `private.ts`, so restoring the tag alone puts nothing on the website. The tag is necessary, not sufficient: it decides whether `deno doc` *can* see a file's module documentation, and what the build is pointed at is a separate question. Both §2 and the issue say that now, and widening the glob is named as the other half. Also added: a task to correct `todo/migrate-typescript-to-mjs.md`, which restates the old rule verbatim under "Module header and import ordering" rather than linking §2, so it did not move when §2 did. Documentation only. `npx tsc` clean, suite 3533/3533. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- fjs/AGENTS.md | 20 ++++++++++----- fjs/todo/module-tag-restore.md | 47 ++++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/fjs/AGENTS.md b/fjs/AGENTS.md index 0c3996346..b3f75061c 100644 --- a/fjs/AGENTS.md +++ b/fjs/AGENTS.md @@ -48,18 +48,19 @@ 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: +Its leading JSDoc block may include, for example: ```js /** * ... * - * @module - * * @import { Phantom } from '../phantom/types.ts' */ ``` +No `@module`: a proof's documentation is not published, so the tag has nothing +to attach it to (§2). + 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 @@ -149,11 +150,16 @@ nothing; `proof.*` is the clear case. Put it in the leading block, followed by one blank line before the first source-level import or declaration. -The tree does not obey this yet. #1756 stripped the tag from 102 files on the -older reading, 98 of which had real prose, so their module documentation is -currently invisible to `deno doc`. Restoring it is +The tree does not obey this yet. #1756 stripped the tag from 102 files and +#1750 from 16 `private.ts`, on the older reading; the prose survives in source +and `deno doc` cannot see it. Restoring it is [`fjs/todo/module-tag-restore.md`](./todo/module-tag-restore.md); until that -lands, a `types.ts` without the tag is debt rather than an example to copy. +lands, an untagged `types.ts` or `private.ts` is debt rather than an example to +copy. + +The tag is necessary, not sufficient. It decides whether `deno doc` *can* see a +file's module documentation; whether anything is generated from that file is a +separate question of what the documentation build is pointed at. 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 diff --git a/fjs/todo/module-tag-restore.md b/fjs/todo/module-tag-restore.md index 38ade118b..8eabd9bc2 100644 --- a/fjs/todo/module-tag-restore.md +++ b/fjs/todo/module-tag-restore.md @@ -12,11 +12,18 @@ module documentation: `deno doc` emits `module_doc` for a file carrying it and nothing at all for a file without it — the block is dropped, not demoted. Verified against the pinned Deno, for `.mjs` and `.ts` alike. -98 of those 102 files had real prose in the block. Their module documentation is -still in the source and is now invisible to `deno doc`, which +98 of those 102 files had real prose in the block, and +[#1750](https://github.com/functionalscript/functionalscript/pull/1750) did the +same to 16 `private.ts` on the same reading — all 16 have prose and none carry +the tag. Their module documentation is still in the source and `deno doc` cannot +see it. Nothing is broken at runtime. + +The tag is necessary, not sufficient. [`../website/todo/publish-deno-doc-to-website.md`](../website/todo/publish-deno-doc-to-website.md) -plans to publish. Nothing is broken at runtime; what is lost is the description -of 89 type-level APIs in generated documentation. +currently plans `deno doc --html **/module.f.mjs`, a glob that excludes every +`types.ts` and `private.ts`, so restoring the tag alone would not put these +descriptions on the website. Restoring it is what makes them *available* to be +published; widening that glob is the other half, and belongs to that issue. [`../AGENTS.md`](../AGENTS.md) §2 now states the rule correctly — the tag goes wherever a file has module-level documentation to publish. This issue is the @@ -24,7 +31,7 @@ tree catching up. ### Proposal -Three groups, and only the first is mechanical. +Four groups, and the first two are mechanical. **1. Restore — 89 `types.ts`, all with prose.** Put `@module` back in the leading block. `types.ts` is the entry point of the type-level API and its @@ -35,13 +42,17 @@ emitted declarations are what a package consumer reads, so this is squarely git show 0233904^: ``` -**2. Decide — 11 proof files** (8 with prose, 3 with a bare tag). Proof -documentation is not published, so by the rule the tag buys nothing and they -should stay as they are. Worth confirming rather than assuming: if `deno doc` -is ever pointed at proofs, or a reader is expected to browse them, the answer -flips. The three bare-tag ones lost nothing either way. +**2. Restore — 16 `private.ts`, all with prose.** Stripped by #1750 rather than +#1756, so they are not in the 102, but the same reading and the same fix. +`../AGENTS.md` §2 names `private.ts` alongside `types.ts`. + +**3. Leave — 11 proof files** (8 with prose, 3 with a bare tag). Proof +documentation is not published, so by the rule the tag has nothing to attach to, +and `../AGENTS.md` §1.2's proof example now shows a block without it. Worth +confirming rather than assuming: if `deno doc` is ever pointed at proofs, the +answer flips. The three bare-tag ones lost nothing either way. -**3. Judge individually — two files that are neither.** +**4. Judge individually — two files that are neither.** - `fjs/bnf/testlib.f.mjs` — its block held only `@import` tags, no prose. Under the rule there is nothing to attach, so it wants no tag. Nothing to restore. @@ -52,10 +63,17 @@ flips. The three bare-tag ones lost nothing either way. ### Tasks - [ ] Restore the tag in the 89 `types.ts` files. +- [ ] Restore it in the 16 `private.ts` files. - [ ] Restore `fjs/emergent_testing/browser.mjs`; leave `fjs/bnf/testlib.f.mjs`. -- [ ] Decide the proof files, and record the decision in +- [ ] Confirm the proof decision, and record it in [`../AGENTS.md`](../AGENTS.md) §2 rather than only here. -- [ ] Drop §2's paragraph saying the tree does not obey the rule yet. +- [ ] Correct the copy of the old rule in + [`../../todo/migrate-typescript-to-mjs.md`](../../todo/migrate-typescript-to-mjs.md) + ("Module header and import ordering"), which still states the tag belongs + only to an entry point — it was restated there rather than linked, so it + did not move when §2 did. +- [ ] Drop §2's paragraph saying the tree does not obey the rule yet, once it + does. - [ ] Spot-check with `deno doc --json` on a restored file that `module_doc` comes back, rather than trusting the edit. @@ -63,6 +81,7 @@ flips. The three bare-tag ones lost nothing either way. - [`../AGENTS.md`](../AGENTS.md) §2 — the rule, and why the tag exists. - [`../website/todo/publish-deno-doc-to-website.md`](../website/todo/publish-deno-doc-to-website.md) - — what makes this visible rather than theoretical. + — the other half: its `**/module.f.mjs` glob would have to widen before any of + this reaches a reader. - [`../../todo/jsdoc-verification.md`](../../todo/jsdoc-verification.md) — how a rule like this might be checked at all, which is why it drifted twice unnoticed. From 908bc0f2d061553bcc26efa02e9abec0f232f5ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:26:59 +0000 Subject: [PATCH 276/370] ci: quote the response-file paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsc splits an @response file on whitespace, so a declaration under a directory with a space in its name was read as two root files. Reproduced before fixing: a package shipping "space dir/a.d.ts" failed with TS6053 "File 'dir/a.d.ts' not found" — a valid package rejected by the check rather than by its own contents. find -printf '"%p"\n' quotes each path. One flag, no extra command: the same package now passes, this repository's 396 declarations are unchanged, and a dangling private import is still caught with TS2307. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 2 +- fjs/ci/package/module.f.mjs | 6 ++++-- fjs/ci/package/proof.f.mjs | 3 +++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45186fd22..515fc5f8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -545,7 +545,7 @@ "run": "npm install \"typescript@=7.0.2\"" }, { - "run": "find node_modules/packed \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) > declarations.txt\ntest -s declarations.txt" + "run": "find node_modules/packed \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) -printf '\"%p\"\\n' > declarations.txt\ntest -s declarations.txt" }, { "run": "npx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt" diff --git a/fjs/ci/package/module.f.mjs b/fjs/ci/package/module.f.mjs index c5a0801f9..48fec2c42 100644 --- a/fjs/ci/package/module.f.mjs +++ b/fjs/ci/package/module.f.mjs @@ -41,8 +41,10 @@ const installCompiler = /** @type {const} */ (`npm install "typescript@${typescr // Every declaration the package ships, enumerated from the installed artifact: // a hand-written import list cannot see a module that gains a private type // module later, which is the case this check exists to catch. An empty list -// would type-check nothing and pass. -const enumerateDeclarations = /** @type {const} */ (`find node_modules/${alias} \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) > declarations.txt +// would type-check nothing and pass. Each path is quoted because `tsc` splits +// a response file on whitespace, so a directory with a space in its name would +// otherwise fail a package that is perfectly valid. +const enumerateDeclarations = /** @type {const} */ (`find node_modules/${alias} \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) -printf '"%p"\\n' > declarations.txt test -s declarations.txt`) // skipLibCheck stays at its false default: it is what makes tsc open these diff --git a/fjs/ci/package/proof.f.mjs b/fjs/ci/package/proof.f.mjs index 1274841a5..b726be821 100644 --- a/fjs/ci/package/proof.f.mjs +++ b/fjs/ci/package/proof.f.mjs @@ -51,6 +51,9 @@ export const proof = { // More than one archive leaves it ambiguous which package is under test. assert(scriptHas('test "$(ls *.tgz | wc -l)" -eq 1'), 'expected exactly one archive required') assert(scriptHas('find node_modules/packed'), 'expected declarations enumerated from that directory') + // `tsc` splits a response file on whitespace, so an unquoted path with a + // space in it fails a package that is valid. + assert(scriptHas(`-printf '"%p"`), 'expected response-file paths quoted') // Every declaration form the package can ship, not just the two this // repository happens to emit — `fjs ci` generates for projects whose // `files` may include CommonJS declarations. From 9cb847157e527b10293a4b7d486b73687148aef1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:27:03 +0000 Subject: [PATCH 277/370] fjs: private.ts prose is contributor-facing, not website input Codex P2 on b7d8e2d: fjs/todo/module-tag-restore.md told the website issue to widen its deno doc glob to types.ts and private.ts alike. Doing that would put all 16 private.ts modules and their exported _ types on the public API site, which is what separate-private-types.md exists to prevent: those types are outside the public declaration closure, and its Stage 2 drops their generated declarations from the package. Restoring the tag in private.ts still stands - the prose is reachable by deno doc for a contributor either way - so the split is about audience, not about the tag. AGENTS.md section 2 said documentation to publish, which conflated the two; it now says documentation a reader is meant to get from deno doc, and names which reader per file kind. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- fjs/AGENTS.md | 18 +++++++++++++----- fjs/todo/module-tag-restore.md | 34 ++++++++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/fjs/AGENTS.md b/fjs/AGENTS.md index b3f75061c..1faa78133 100644 --- a/fjs/AGENTS.md +++ b/fjs/AGENTS.md @@ -141,11 +141,19 @@ without it gets no `module_doc` at all — the block is dropped, not demoted. Verified against the pinned Deno (`fjs/ci/config/module.f.mjs`), for `.mjs` and `.ts` alike; the tag need not be in the first block, only in some block. -**So the tag goes wherever a file has module-level documentation to publish** — -`module.f.mjs`, `types.ts`, `private.ts` — and a `module.*` file always has some. -A file whose leading block only holds `@import` tags has nothing to attach and -wants no `@module`. Where a file's documentation is never published, the tag buys -nothing; `proof.*` is the clear case. +**So the tag goes wherever a file has module-level documentation a reader is +meant to get from `deno doc`** — `module.f.mjs`, `types.ts`, `private.ts` — and a +`module.*` file always has some. A file whose leading block only holds `@import` +tags has nothing to attach and wants no `@module`. Where a file's documentation +reaches no reader, the tag buys nothing; `proof.*` is the clear case. + +Which reader differs by file kind, and the tag does not decide it. +`module.f.mjs` and `types.ts` are public API surface. `private.ts` is not: it +holds implementation-private types outside the public declaration closure, and +[`todo/separate-private-types.md`](./todo/separate-private-types.md) plans to +drop its generated declarations from the package altogether. Its prose is for +contributors reading the sources, so the tag belongs there — but a public +documentation build must not be pointed at it. Put it in the leading block, followed by one blank line before the first source-level import or declaration. diff --git a/fjs/todo/module-tag-restore.md b/fjs/todo/module-tag-restore.md index 8eabd9bc2..b9daa1010 100644 --- a/fjs/todo/module-tag-restore.md +++ b/fjs/todo/module-tag-restore.md @@ -23,11 +23,21 @@ The tag is necessary, not sufficient. currently plans `deno doc --html **/module.f.mjs`, a glob that excludes every `types.ts` and `private.ts`, so restoring the tag alone would not put these descriptions on the website. Restoring it is what makes them *available* to be -published; widening that glob is the other half, and belongs to that issue. +read at all; where they are then shown is a separate decision, and it is not the +same decision for the two file kinds. + +**`types.ts` yes, `private.ts` no.** `types.ts` is the public type-level API, so +widening that glob to reach it belongs to the website issue. `private.ts` holds +implementation-private types outside the public declaration closure, and +[`separate-private-types.md`](./separate-private-types.md) plans to drop its +generated declarations from the package in Stage 2 — putting them on the public +API site would publish exactly what that design removes. Its prose is worth the +tag for contributors reading the sources or running `deno doc` themselves; it is +not website input. [`../AGENTS.md`](../AGENTS.md) §2 now states the rule correctly — the tag goes -wherever a file has module-level documentation to publish. This issue is the -tree catching up. +wherever a file has module-level documentation a reader is meant to get from +`deno doc`, whoever that reader is. This issue is the tree catching up. ### Proposal @@ -36,7 +46,7 @@ Four groups, and the first two are mechanical. **1. Restore — 89 `types.ts`, all with prose.** Put `@module` back in the leading block. `types.ts` is the entry point of the type-level API and its emitted declarations are what a package consumer reads, so this is squarely -"documentation to publish". The exact text is recoverable per file: +documentation a reader is meant to get. The exact text is recoverable per file: ```sh git show 0233904^: @@ -44,7 +54,10 @@ git show 0233904^: **2. Restore — 16 `private.ts`, all with prose.** Stripped by #1750 rather than #1756, so they are not in the 102, but the same reading and the same fix. -`../AGENTS.md` §2 names `private.ts` alongside `types.ts`. +`../AGENTS.md` §2 names `private.ts` alongside `types.ts`, and now says why the +audience is not the same one: the tag makes the prose reachable by `deno doc` for +a contributor, and the public site must stay pointed away from these files. Do +not carry this group into the website glob. **3. Leave — 11 proof files** (8 with prose, 3 with a bare tag). Proof documentation is not published, so by the rule the tag has nothing to attach to, @@ -63,7 +76,8 @@ answer flips. The three bare-tag ones lost nothing either way. ### Tasks - [ ] Restore the tag in the 89 `types.ts` files. -- [ ] Restore it in the 16 `private.ts` files. +- [ ] Restore it in the 16 `private.ts` files, without adding them to any public + documentation build. - [ ] Restore `fjs/emergent_testing/browser.mjs`; leave `fjs/bnf/testlib.f.mjs`. - [ ] Confirm the proof decision, and record it in [`../AGENTS.md`](../AGENTS.md) §2 rather than only here. @@ -81,7 +95,11 @@ answer flips. The three bare-tag ones lost nothing either way. - [`../AGENTS.md`](../AGENTS.md) §2 — the rule, and why the tag exists. - [`../website/todo/publish-deno-doc-to-website.md`](../website/todo/publish-deno-doc-to-website.md) - — the other half: its `**/module.f.mjs` glob would have to widen before any of - this reaches a reader. + — the other half for group 1: its `**/module.f.mjs` glob would have to widen to + `types.ts` before those descriptions reach a website reader. Not to + `private.ts`. +- [`separate-private-types.md`](./separate-private-types.md) — why `private.ts` + is contributor-facing only, and why Stage 2 drops its declarations from the + package. - [`../../todo/jsdoc-verification.md`](../../todo/jsdoc-verification.md) — how a rule like this might be checked at all, which is why it drifted twice unnoticed. From e34ac5c065e60d9d5578859210d37fdbf301dbd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:35:22 +0000 Subject: [PATCH 278/370] rtti: attach each hostile-value helper's doc to its own function Review finding on #1766: the restoring helper was inserted anchored on the deleting helper's declaration, so both doc blocks ended up stacked above the new function and lengthGetterDeletesAMember was left undocumented. Reordered so each block sits with the function it describes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/rtti/host.proof.mjs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index d33779699..c2fcf3e03 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -93,6 +93,20 @@ const beyondIndexRange = () => Object.assign([1], { '4294967295': 2 }) * * @type {() => readonly Unknown[]} */ +const lengthGetterDeletesAMember = () => { + const target = ['bad'] + let fired = false + return new Proxy(target, { + get: (o, k, r) => { + if (k === 'length' && !fired) { + fired = true + delete o[0] + } + return Reflect.get(o, k, r) + }, + }) +} + /** * The same, but the member comes **back** on the second `length` read — the * variant that defeats a pre-read snapshot alone: the walk skips a member the @@ -115,20 +129,6 @@ const lengthGetterRestoresAMember = () => { }) } -const lengthGetterDeletesAMember = () => { - const target = ['bad'] - let fired = false - return new Proxy(target, { - get: (o, k, r) => { - if (k === 'length' && !fired) { - fired = true - delete o[0] - } - return Reflect.get(o, k, r) - }, - }) -} - export const proof = { // `undeclaredMembers` decides a container's members by what an index From 99b3a448410a9251078943b95ecb4aaf1a819826 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:47:50 +0000 Subject: [PATCH 279/370] rtti: decide each member once, then use the decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #1766: the prepass consulted an absent member's schema and the walk consulted it again, so a stateful thunk saw two evaluations where it had seen one. Against [statefulThunk, number] with a getter keyed to the thunk's phase, both readers returned ok where main and the data form reject. The walk now consumes the presence it was given instead of re-deriving it: presence is probed once per member, before the bound, and the walk reads that recorded flag rather than testing "k in value" a second time. An absent member's schema is consulted once, and one presenceUnchanged suffices, since the walk records the decisions it was handed. That restores the pre-change operation counts at the value — one probe per member in the walk, one in presenceUnchanged — and closes two variants at once: the stateful thunk, and the has trap that swaps a member on its second probe, which the previous revision accepted. Both are pinned in host.proof.mjs as decisionsAreMadeOnceAndReused; the entry fails when the walk re-derives the decision, checked in both directions. Unchanged: 0 acceptance differences over the 1550-pair differential against main, chains linear to depth 400, full suite 3537, coverage 100/100/100. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/rtti/host.proof.mjs | 48 +++++++++++++++++++++++++ fjs/rtti/parse/module.f.mjs | 37 ++++++-------------- fjs/rtti/validate/module.f.mjs | 64 +++++++++++++++------------------- 3 files changed, 87 insertions(+), 62 deletions(-) diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index c2fcf3e03..a151b5e3e 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -210,6 +210,54 @@ export const proof = { assertError(rv) } }, + // Each declared member's presence is probed once and then *used*, and an + // absent member's schema is consulted once — so neither a value that + // answers `HasProperty` differently the second time nor a schema thunk + // that counts its evaluations can move the verdict. Both are values only + // the host can build, and both were accepted by an earlier revision of + // the reader that asked each question twice. + decisionsAreMadeOnceAndReused: () => { + // a `has` trap that swaps the member on its second probe + const hasSwapsOnTheSecondProbe = () => { + /** @type {Unknown[]} */ + const target = ['bad'] + let n = 0 + return new Proxy(target, { + has: (o, k) => { + if (k === '0') { + n += 1 + if (n === 2) { o[0] = 1 } + } + return Reflect.has(o, k) + }, + }) + } + for (const read of [v, p, d]) { + assertError(read([number])(hasSwapsOnTheSecondProbe())) + } + // a schema thunk that counts its evaluations, beside a member whose + // getter answers by that count: one extra evaluation would hand the + // walk a different member than the one the schema was decided for + const phased = () => { + let phase = 0 + /** @type {Type} */ + const optional = () => { + phase += 1 + return /** @type {any} */ (['or', option, number]) + } + const value = Object.defineProperty(new Array(2), '1', { + get: () => phase === 2 ? 1 : 'bad', + enumerable: true, + configurable: true, + }) + return /** @type {readonly [Type, readonly Unknown[]]} */ ( + [[optional, number], value]) + } + for (const read of [v, p, d]) { + const [t, value] = phased() + assertError(read(t)(value)) + } + }, // …and what they agree on, which is what the changelog entry claims. inheritedIndexMeetsTheRest: () => { const value = inheritedIndex() diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 58257687f..398693a59 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -58,10 +58,8 @@ import { ok } from '../../types/result/module.f.mjs' import { absentMember, - consPresence, constPrimitiveValidate, eachEntry, - emptyPresence, isArray, isObject, orVisit, @@ -342,29 +340,18 @@ const constContainerParse = if (!isContainer(value)) { return verror('unexpected value') } - // Presence first, then the bound, then the reads — see the - // comment on the same shape in `../validate/module.f.mjs`. The - // length read lands after the decisions it could otherwise - // steer, and bounding before the reads is what lets an `or` of - // two arities settle an arm without recursing. - const presence = eachEntry( - rttiEntries, - (k, t) => { - if (k in value) { return ok(true) } - const a = absentMember(t) - return a[0] === 'error' ? a : ok(false) - }, - emptyPresence, - consPresence, - ) - if (presence[0] === 'error') { return presence } + // Probe presence once, bound, then read — each decision made + // once and then used, never re-derived. See the comment on the + // same shape in `../validate/module.f.mjs`. + const withPresence = rttiEntries.map(([k, t]) => + /** @type {readonly[string, readonly[typeof t, boolean]]} */ ([k, [t, k in value]])) if (!fits(value, declared.length)) { return verror('unexpected value') } const r = eachEntry( - rttiEntries, - (k, t) => { - if (!(k in value)) { + withPresence, + (k, [t, present]) => { + if (!present) { const a = absentMember(t) return a[0] === 'error' ? a : ok([]) } @@ -378,11 +365,9 @@ const constContainerParse = if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { return verror('unexpected value') } - // Both the pre-bound presence and the walk's own, as in - // `../validate/module.f.mjs` — each catches a flip the other - // does not. - if (!presenceUnchanged(rttiEntries, presence[1], value) - || !presenceUnchanged(rttiEntries, r[1].presence, value)) { + // The walk recorded the decisions it was given, so this one + // comparison is against the pre-bound snapshot. + if (!presenceUnchanged(rttiEntries, r[1].presence, value)) { return verror('unexpected value') } const built = /** @type {ReadonlyArray | StringMap} */ (rebuild(r[1].entries)) diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index 0597c4376..8de288722 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -215,42 +215,38 @@ const constContainerValidate = if (!isContainer(value)) { return verror('unexpected value') } - // Decide every declared member's **presence** first, then bound - // the container, and only then read the members. Presence is a - // `HasProperty` probe and recurses into nothing, so this pass is - // cheap; what it buys is that the length read happens after the - // decisions it could otherwise steer, and `presenceUnchanged` - // below still re-asks them against the final state — so a - // `length` getter that mutates a declared member is caught - // rather than obeyed, and the three readers keep agreeing. + // Probe every declared member's **presence** first, bound the + // container next, and read the members last — each member's + // decision made once and then *used*, never re-derived. + // + // Deciding before the bound is what keeps the length read from + // steering a decision, and probing once is what keeps the walk + // from asking a question the value could answer differently the + // second time: the walk consumes `present` rather than testing + // `k in value` again, and an absent member's schema is consulted + // once, so a stateful thunk sees one evaluation as it did before. + // The counts match the pre-change reader — one probe per member + // here, one in `presenceUnchanged` below — so nothing new is + // dispatched at the value except the bound itself. // // Bounding before the reads is load-bearing for an `or` of two // arities, the shape a schema uses to say a trailing operand may // be left out (`fjs/edag`'s chain nodes). Without it each arm - // walks the shared operands before failing on length, so - // validating a nested chain costs 2^depth; with it the arm is - // settled before any recursion — by the bound for the value that - // is too long, and by the absent last member for the one that is - // too short. `parse` does the same, which is what keeps the two - // readers reporting the same error. - const presence = eachEntry( - rttiEntries, - (k, v) => { - if (k in value) { return ok(true) } - const a = absentMember(v) - return a[0] === 'error' ? a : ok(false) - }, - emptyPresence, - consPresence, - ) - if (presence[0] === 'error') { return presence } + // walks the shared operands before failing, so validating a + // nested chain costs 2^depth; with it each arm is settled before + // any recursion — by the bound where the value is too long, and + // by the absent last member where it is too short. `parse` does + // the same, which is what keeps the two readers reporting the + // same error. + const withPresence = rttiEntries.map(([k, v]) => + /** @type {readonly[string, readonly[typeof v, boolean]]} */ ([k, [v, k in value]])) if (!fits(value, declared.length)) { return verror('unexpected value') } const r = eachEntry( - rttiEntries, - (k, v) => { - if (!(k in value)) { + withPresence, + (k, [v, present]) => { + if (!present) { const a = absentMember(v) return a[0] === 'error' ? a : ok(false) } @@ -266,14 +262,10 @@ const constContainerValidate = } // `value` is C (Unknown container), but Ts for T extends Tuple|Struct is not // structurally equivalent to C — TypeScript can't narrow element types through the loop. - // Against **both** snapshots: the one decided before the bound - // was read, and the walk's own. A `length` getter that deletes a - // declared member is caught by the first, and one that deletes it - // on its first read and restores it on its second — leaving the - // walk to skip a member the value ends up carrying unvalidated — - // is caught by the second. Either alone leaves the other open. - return presenceUnchanged(rttiEntries, presence[1], value) - && presenceUnchanged(rttiEntries, r[1], value) + // One comparison suffices: the walk recorded the decisions it + // was given, so `r[1]` *is* the pre-bound snapshot, and a member + // lost or restored across the reads shows up as a flip here. + return presenceUnchanged(rttiEntries, r[1], value) ? /** @type {any} */ (ok(value)) : verror('unexpected value') } From 4e388296a8807e1c8239c263b397b11f3e0a7d3f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:56:26 +0000 Subject: [PATCH 280/370] rtti: state the DJS assumption instead of defending against JS values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The readers are written for values FunctionalScript can build, where reading a member has no effect. Three rounds of review found hostile-value variants of one shape — a getter or trap that answers differently each time it is asked — and the last of them is not closable at all: bounding a container before recursing means reading length before the member values, so a length getter always decides what the members are. Defending the rest bought extra probes, extra thunk evaluations and a second presence comparison, each of which was itself the next variant's opening. So the assumption is now stated rather than fought. "What the readers assume of a value" in README.md says the readers are for DJS values, names the length-before-members ordering as the one place that is given up by design, gives the worked example that separates the readers, and records that hardening against values the language cannot produce costs roughly 40% of validation time on values it can. The hostile-accessor todo cross-references it, so the two read as one position rather than as a gap. The implementation keeps only what the performance fix needs — presence, then the bound, then the reads, each decision made once and used — and the two host.proof fixtures that pinned hostile-value verdicts are dropped, since those are no longer promises. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AQH2SZCpDhyQUgByxkV46e --- fjs/rtti/README.md | 42 +++++++ fjs/rtti/host.proof.mjs | 117 ------------------ fjs/rtti/parse/module.f.mjs | 11 +- .../hostile-accessor-hermetic-read-path.md | 7 ++ fjs/rtti/validate/module.f.mjs | 42 +++---- 5 files changed, 73 insertions(+), 146 deletions(-) diff --git a/fjs/rtti/README.md b/fjs/rtti/README.md index e88dd0ea6..88c550884 100644 --- a/fjs/rtti/README.md +++ b/fjs/rtti/README.md @@ -77,6 +77,48 @@ For reading a value straight from JSON text against a schema, see one pass, no intermediate value, and it can reject `1.00000000000000001` against a `bigint`, which no reader over an already-materialized value can do. +## What the readers assume of a value + +The readers are written for **DJS values**: plain arrays and objects of +primitives, the values FunctionalScript itself can build. Reading a member of +one of those has no effect, and every guarantee below rests on that. + +A value built by arbitrary JavaScript need not behave that way. A getter or a +`Proxy` trap runs code on being read, so for such a value *asking a question +is an action*, and the readers do not defend against it. Concretely: + +- **A closed container is bounded by `length` before its members are read**, + which is what keeps an `or` of two arities from walking a shared operand + once per arm — validating a deeply nested `../edag/` chain is linear rather + than exponential because of it. The cost is that a `length` getter runs + before the members are read and can decide what they are: a proxy over + `['bad']` whose `length` getter sets index 0 to `1` is **accepted** against + `[number]` by `parse` and `validate`, while the data form rejects it. +- **So the three readers can disagree on such a value.** The agreement the + tables in `validate/proof.f.mjs` pin — and that `host.proof.mjs` holds them + to — is a promise about values whose reads are side-effect-free, which is + every DJS value and every ordinary array. It is not a promise about a value + engineered to answer differently each time it is asked. +- **More of the verdict path is steerable than that one read**, by patching + the intrinsics the readers use rather than the value they read; + [`todo/hostile-accessor-hermetic-read-path.md`](./todo/hostile-accessor-hermetic-read-path.md) + enumerates those and is where hardening work belongs if it is ever wanted. + +What still holds for any value at all: a reader returns a `Result` rather than +throwing on ordinary input, and `validate` hands back the value it was given +rather than a reconstruction. What is *not* promised for a hostile one is that +its verdict matches another reader's, or that the value still denotes what was +checked by the time the reader returns. + +This is a deliberate boundary, not an oversight. Hardening the readers against +values the language cannot produce costs speed on every value it can: the +prototype-chain walk that finds inherited indices and the presence re-check +that catches a flipped member together account for roughly 40% of validation +time on a graph of small containers. A caller reading genuinely untrusted +JavaScript should convert it to DJS first — or parse from text, where +[`../media/json/todo/rtti-parse.md`](../media/json/todo/rtti-parse.md) reads +against a schema in one pass with no intermediate value to subvert. + ## Schema types A `Type` is one of: diff --git a/fjs/rtti/host.proof.mjs b/fjs/rtti/host.proof.mjs index a151b5e3e..1a3fec618 100644 --- a/fjs/rtti/host.proof.mjs +++ b/fjs/rtti/host.proof.mjs @@ -85,51 +85,6 @@ const shadowedIndex = () => { */ const beyondIndexRange = () => Object.assign([1], { '4294967295': 2 }) -/** - * An array whose **first** `length` read deletes a declared member, which is - * the shape that catches a reader bounding a container before it has decided - * what its members are. `Array.isArray` sees through the proxy, so all three - * readers take their array path. - * - * @type {() => readonly Unknown[]} - */ -const lengthGetterDeletesAMember = () => { - const target = ['bad'] - let fired = false - return new Proxy(target, { - get: (o, k, r) => { - if (k === 'length' && !fired) { - fired = true - delete o[0] - } - return Reflect.get(o, k, r) - }, - }) -} - -/** - * The same, but the member comes **back** on the second `length` read — the - * variant that defeats a pre-read snapshot alone: the walk skips a member the - * value ends up carrying, so the value is accepted with a member no reader - * ever validated unless the walk's own presence is enforced too. - * - * @type {() => readonly Unknown[]} - */ -const lengthGetterRestoresAMember = () => { - const target = ['bad'] - let n = 0 - return new Proxy(target, { - get: (o, k, r) => { - if (k === 'length') { - n += 1 - if (n === 1) { delete o[0] } else if (n === 2) { o[0] = 'bad' } - } - return Reflect.get(o, k, r) - }, - }) -} - - export const proof = { // `undeclaredMembers` decides a container's members by what an index // *reads*, so both of these are members — one that an own-entry walk @@ -186,78 +141,6 @@ export const proof = { assertEq(rv[0], d(t)(value)[0], 'the data form must agree too') } }, - // A `length` getter that deletes a declared member. The readers bound a - // container by its length, so this getter fires inside their walk — and - // each reader needs its **own** instance, because the mutation is - // one-shot: share it and the first reader absorbs it, leaving the others - // a value that is merely short. Presence is decided before the bound is - // read and re-asked against the final state, so the deletion is caught on - // every reader instead of steering one of them into an accept. - lengthGetterCannotSteerTheVerdict: () => { - /** @type {readonly (readonly [Type, () => readonly Unknown[]])[]} */ - const rows = [ - [[or(option, number)], lengthGetterDeletesAMember], - [[number], lengthGetterDeletesAMember], - [[or(option, number)], lengthGetterRestoresAMember], - [[number], lengthGetterRestoresAMember], - ] - for (const [t, mk] of rows) { - const rv = v(t)(mk()) - assertEq(rv[0], p(t)(mk())[0], 'validate and parse must agree') - assertEq(rv[0], d(t)(mk())[0], 'the data form must agree too') - // and what they agree on: the member the schema declared was - // there when it was decided, so losing it is a rejection - assertError(rv) - } - }, - // Each declared member's presence is probed once and then *used*, and an - // absent member's schema is consulted once — so neither a value that - // answers `HasProperty` differently the second time nor a schema thunk - // that counts its evaluations can move the verdict. Both are values only - // the host can build, and both were accepted by an earlier revision of - // the reader that asked each question twice. - decisionsAreMadeOnceAndReused: () => { - // a `has` trap that swaps the member on its second probe - const hasSwapsOnTheSecondProbe = () => { - /** @type {Unknown[]} */ - const target = ['bad'] - let n = 0 - return new Proxy(target, { - has: (o, k) => { - if (k === '0') { - n += 1 - if (n === 2) { o[0] = 1 } - } - return Reflect.has(o, k) - }, - }) - } - for (const read of [v, p, d]) { - assertError(read([number])(hasSwapsOnTheSecondProbe())) - } - // a schema thunk that counts its evaluations, beside a member whose - // getter answers by that count: one extra evaluation would hand the - // walk a different member than the one the schema was decided for - const phased = () => { - let phase = 0 - /** @type {Type} */ - const optional = () => { - phase += 1 - return /** @type {any} */ (['or', option, number]) - } - const value = Object.defineProperty(new Array(2), '1', { - get: () => phase === 2 ? 1 : 'bad', - enumerable: true, - configurable: true, - }) - return /** @type {readonly [Type, readonly Unknown[]]} */ ( - [[optional, number], value]) - } - for (const read of [v, p, d]) { - const [t, value] = phased() - assertError(read(t)(value)) - } - }, // …and what they agree on, which is what the changelog entry claims. inheritedIndexMeetsTheRest: () => { const value = inheritedIndex() diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 398693a59..04e8f862c 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -340,9 +340,10 @@ const constContainerParse = if (!isContainer(value)) { return verror('unexpected value') } - // Probe presence once, bound, then read — each decision made - // once and then used, never re-derived. See the comment on the - // same shape in `../validate/module.f.mjs`. + // Presence, then the bound, then the reads — each decision + // made once and then used. See the comment on the same shape in + // `../validate/module.f.mjs`, including what reading `length` + // first assumes of the value. const withPresence = rttiEntries.map(([k, t]) => /** @type {readonly[string, readonly[typeof t, boolean]]} */ ([k, [t, k in value]])) if (!fits(value, declared.length)) { @@ -365,8 +366,8 @@ const constContainerParse = if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { return verror('unexpected value') } - // The walk recorded the decisions it was given, so this one - // comparison is against the pre-bound snapshot. + // The walk recorded the decisions it was given, so this asks + // the pre-bound snapshot against the final state. if (!presenceUnchanged(rttiEntries, r[1].presence, value)) { return verror('unexpected value') } diff --git a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md index e92e2e37f..1e2edb3f8 100644 --- a/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md +++ b/fjs/rtti/todo/hostile-accessor-hermetic-read-path.md @@ -39,6 +39,13 @@ accessor has already run arbitrary code in the host, so this hardening is about the readers' own answers staying theirs, not about containing the host. +Note that the readers do not currently claim otherwise: "What the readers +assume of a value" in [`../README.md`](../README.md) states the DJS +assumption and names the one ordering that gives it up by design — a closed +container is bounded by `length` before its members are read, so a `length` +getter decides what they are. This issue is the work of *narrowing* that, +should it ever be wanted; nothing depends on it today. + ### Tasks - [ ] Extend the discipline the rebuilds and `eachEntry` state to the diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index 8de288722..593fd1d6a 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -215,29 +215,24 @@ const constContainerValidate = if (!isContainer(value)) { return verror('unexpected value') } - // Probe every declared member's **presence** first, bound the - // container next, and read the members last — each member's - // decision made once and then *used*, never re-derived. + // Decide each declared member's presence, bound the container, + // then read the members — in that order, and each decision made + // once and then used rather than re-derived. // - // Deciding before the bound is what keeps the length read from - // steering a decision, and probing once is what keeps the walk - // from asking a question the value could answer differently the - // second time: the walk consumes `present` rather than testing - // `k in value` again, and an absent member's schema is consulted - // once, so a stateful thunk sees one evaluation as it did before. - // The counts match the pre-change reader — one probe per member - // here, one in `presenceUnchanged` below — so nothing new is - // dispatched at the value except the bound itself. + // The order is what makes an `or` of two arities linear instead + // of 2^depth, which is the shape a schema uses to say a trailing + // operand may be left out (`fjs/edag`'s chain nodes). Both steps + // earn their place, in opposite directions: the bound settles the + // arm whose value is too long, and the presence pass settles the + // one whose value is too short, by reaching its absent last + // member before any recursion. `parse` does the same, which is + // what keeps the two readers reporting the same error. // - // Bounding before the reads is load-bearing for an `or` of two - // arities, the shape a schema uses to say a trailing operand may - // be left out (`fjs/edag`'s chain nodes). Without it each arm - // walks the shared operands before failing, so validating a - // nested chain costs 2^depth; with it each arm is settled before - // any recursion — by the bound where the value is too long, and - // by the absent last member where it is too short. `parse` does - // the same, which is what keeps the two readers reporting the - // same error. + // Reading `length` before the members assumes reading it has no + // effect — true of every DJS value, and the assumption the + // readers are written under. What that gives up for a value built + // by arbitrary JavaScript is stated in "What the readers assume + // of a value" in `../README.md`. const withPresence = rttiEntries.map(([k, v]) => /** @type {readonly[string, readonly[typeof v, boolean]]} */ ([k, [v, k in value]])) if (!fits(value, declared.length)) { @@ -262,9 +257,8 @@ const constContainerValidate = } // `value` is C (Unknown container), but Ts for T extends Tuple|Struct is not // structurally equivalent to C — TypeScript can't narrow element types through the loop. - // One comparison suffices: the walk recorded the decisions it - // was given, so `r[1]` *is* the pre-bound snapshot, and a member - // lost or restored across the reads shows up as a flip here. + // The walk recorded the decisions it was given, so this asks + // the pre-bound snapshot against the final state. return presenceUnchanged(rttiEntries, r[1], value) ? /** @type {any} */ (ok(value)) : verror('unexpected value') From 5688ffa2a09ebdb4ea94d5e517b29e080a46fb4b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 22:57:55 +0000 Subject: [PATCH 281/370] ci: read the compiler pin instead of restating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the compiler pin living in two unlinked places — fjs/ci/config and package.json — with a proof that could not see them disagree, because it imported the same constant the generator interpolated and so compared a value with itself. Setting the config constant to =5.9.3 left the suite green and the job installing the wrong compiler: silent at both layers, unlike every earlier gap in this series. The fix is not a better assertion. It is having one place. The generator already reads Cargo.toml, so it now reads package.json and takes devDependencies .typescript from there. The config constant is gone, and with it the thing that could drift. Changing the pin now changes ci.yml, which node26's existing regeneration check already guards. The proof supplies its own pin, one no configuration holds, so finding it proves the value came from the parameter rather than from a constant compared with itself. Without a pin the check cannot run deterministically, so it is not generated rather than run against a compiler nobody chose; a proof covers each shape that means "no pin", which is also what keeps branch coverage at 100%. Comments no longer travel into the emitted commands: ci.yml is output, and the reasoning belongs in the generator that a maintainer reads. The archive-count guard goes too — two archives already make npm's spec malformed and fail loudly, so it only restated a failure that happens anyway. Verified: 396 declarations and TypeScript 7.0.2 from package.json's own pin, a dangling private import still TS2307 at the type-check step, 3478/3478, coverage 100%, round-trip clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 2 +- fjs/ci/config/module.f.mjs | 7 ------- fjs/ci/module.f.mjs | 42 +++++++++++++++++++++++++++++++------ fjs/ci/package/module.f.mjs | 33 ++++++++++++++++++----------- fjs/ci/package/proof.f.mjs | 34 +++++++++++++++++------------- fjs/ci/proof.f.mjs | 32 +++++++++++++++++++++++++++- 6 files changed, 108 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 515fc5f8a..a7314bc6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -539,7 +539,7 @@ } }, { - "run": "npm init -y > /dev/null\n# One archive, or which package is under test is ambiguous.\ntest \"$(ls *.tgz | wc -l)\" -eq 1\nnpm install \"packed@file:$(ls *.tgz)\"" + "run": "npm init -y > /dev/null\nnpm install \"packed@file:$(ls *.tgz)\"" }, { "run": "npm install \"typescript@=7.0.2\"" diff --git a/fjs/ci/config/module.f.mjs b/fjs/ci/config/module.f.mjs index 4bccb956e..8bed9ba7c 100644 --- a/fjs/ci/config/module.f.mjs +++ b/fjs/ci/config/module.f.mjs @@ -33,13 +33,6 @@ export const bun = '1.4.0' // https://deno.com/ export const deno = '2.9.6' -// The compiler the packed-package check installs. Kept here with the other tool -// pins rather than read out of the artifact at run time: with no checkout that -// job has no lockfile, so the version has to come from somewhere version -// controlled or the registry decides the check's verdict. -// https://www.npmjs.com/package/typescript -export const typescript = '=7.0.2' - // The Node versions the pinned Nixpkgs snapshot below provides — read from // `pkgs/development/web/nodejs/v{22,24,26}.nix` at that commit. Every runtime // uses these: `setup-node` on the GitHub-hosted runners and the generated diff --git a/fjs/ci/module.f.mjs b/fjs/ci/module.f.mjs index cd406ad1f..3ebd8a6ad 100644 --- a/fjs/ci/module.f.mjs +++ b/fjs/ci/module.f.mjs @@ -10,10 +10,12 @@ * @import { NixJob } from './nix/types.ts' * @import { Setup } from './types.ts' * @import { Effect } from '../effects/types.ts' + * @import { Result } from '../types/result/types.ts' + * @import { IoChannel } from '../effects/node/types.ts' */ import { resultStep } from '../effects/module.f.mjs' -import { access, exitStep, writeUtf8File } from '../effects/node/module.f.mjs' +import { access, exitStep, readUtf8File, writeUtf8File } from '../effects/node/module.f.mjs' import { step as ioStep } from '../effects/module.f.mjs' import { functionalscript, images } from './config/module.f.mjs' import { @@ -25,6 +27,7 @@ import { import { rustPlatformSteps, rustWasmSteps } from './rust/module.f.mjs' import { nodeMainSteps, nodeNixJobs, nodeNixVersionSteps, nodeVersionJobs } from './node/module.f.mjs' import { nixFlakes, nixInstall } from './nix/module.f.mjs' +import { parse as jsonParse } from '../media/json/module.f.mjs' import { packageCheckJob, packageCheckJobId } from './package/module.f.mjs' import { bunSteps } from './bun/module.f.mjs' import { denoSteps } from './deno/module.f.mjs' @@ -50,25 +53,52 @@ const nixJobs = nodeNixJobs /** @type {Job} */ const nixFlakeJob = ubuntuArm([nixInstall, ...nodeNixVersionSteps]) -/** @type {(rust: boolean) => Jobs} */ -const canonicalJobs = rust => ({ +/** @type {(rust: boolean, pin: string | undefined) => Jobs} */ +const canonicalJobs = (rust, pin) => ({ ...(rust ? { wasm: ubuntuArm(rustWasmSteps) } : {}), deno: ubuntuArm(denoSteps(functionalscript)), bun: ubuntuArm(bunSteps(functionalscript)), ...nodeVersionJobs(functionalscript), - [packageCheckJobId]: packageCheckJob, + ...(pin === undefined ? {} : { [packageCheckJobId]: packageCheckJob(pin) }), 'nix-flakes': nixFlakeJob, }) +/** + * The compiler the packed-package check installs, read out of the project's own + * `package.json` rather than restated anywhere. A second copy could disagree + * with this one silently, and a check running a compiler the package does not + * pin is a green result about the wrong thing. + * + * `undefined` when there is no package.json or no pin: the check cannot be run + * deterministically then, so it is not generated at all rather than run against + * a compiler nobody chose. + * + * @type {(text: Result) => string | undefined} + */ +const compilerPin = text => { + if (text[0] !== 'ok') { return undefined } + const json = jsonParse(text[1]) + if (json[0] !== 'ok') { return undefined } + const root = json[1] + if (typeof root !== 'object' || root === null || root instanceof Array) { return undefined } + const dev = root.devDependencies + if (typeof dev !== 'object' || dev === null || dev instanceof Array) { return undefined } + const pin = dev.typescript + return typeof pin === 'string' ? pin : undefined +} + /** @type {(setup: Setup) => Effect} */ export const ci = ({ nodeExtra }) => resultStep( + readUtf8File('package.json'), + packageJson => resultStep( access('Cargo.toml'), result => { const rust = result[0] === 'ok' + const pin = compilerPin(packageJson) /** @type {Jobs} */ const jobs = { ...Object.fromEntries(os.flatMap(o => architecture.map(job(rust, nodeExtra(o))(o)))), - ...canonicalJobs(rust), + ...canonicalJobs(rust, pin), } /** @type {GitHubAction} */ const gha = { @@ -87,6 +117,6 @@ export const ci = ({ nodeExtra }) => resultStep( JSON.stringify(gha, null, ' ')) const flakesWritten = ioStep(workflowWritten, () => nixFlakes(nixJobs)) return exitStep(flakesWritten) - }) + })) export const main = () => ci({ nodeExtra: () => [] }) diff --git a/fjs/ci/package/module.f.mjs b/fjs/ci/package/module.f.mjs index 48fec2c42..36a5ff320 100644 --- a/fjs/ci/package/module.f.mjs +++ b/fjs/ci/package/module.f.mjs @@ -7,7 +7,7 @@ * @import { Job } from '../common/types.ts' */ -import { images, node, typescript } from '../config/module.f.mjs' +import { images, node } from '../config/module.f.mjs' import { uses } from '../common/module.f.mjs' import { packageArtifact, packageJobId } from '../node/module.f.mjs' @@ -31,19 +31,28 @@ export const packageCheckJobId = /** @type {const} */ ('package-check') // Nothing here self-references; revisit this if that changes. const alias = /** @type {const} */ ('packed') +// Installed under a fixed alias so every later step names the package +// literally. Two archives would make npm's spec malformed and fail loudly, so +// nothing here guards a count. const installArtifact = /** @type {const} */ (`npm init -y > /dev/null -# One archive, or which package is under test is ambiguous. -test "$(ls *.tgz | wc -l)" -eq 1 npm install "${alias}@file:$(ls *.tgz)"`) -const installCompiler = /** @type {const} */ (`npm install "typescript@${typescript}"`) +/** + * The compiler is whatever the project pins, passed through untouched. With no + * checkout there is no lockfile, so a version chosen here instead would let the + * registry — or a constant that drifted from `package.json` — decide the + * verdict. + * + * @type {(pin: string) => string} + */ +const installCompiler = pin => `npm install "typescript@${pin}"` // Every declaration the package ships, enumerated from the installed artifact: // a hand-written import list cannot see a module that gains a private type // module later, which is the case this check exists to catch. An empty list -// would type-check nothing and pass. Each path is quoted because `tsc` splits -// a response file on whitespace, so a directory with a space in its name would -// otherwise fail a package that is perfectly valid. +// would type-check nothing and pass. Each path is quoted because `tsc` splits a +// response file on whitespace, so a directory with a space in its name would +// otherwise fail a package that is valid. const enumerateDeclarations = /** @type {const} */ (`find node_modules/${alias} \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) -printf '"%p"\\n' > declarations.txt test -s declarations.txt`) @@ -53,11 +62,11 @@ const typeCheck = /** @type {const} */ (`npx tsc --module nodenext --moduleResol /** * Downloads the packed tarball, installs it as a real dependency, and - * type-checks every declaration it ships. + * type-checks every declaration it ships with the compiler the package pins. * - * @type {Job} + * @type {(pin: string) => Job} */ -export const packageCheckJob = { +export const packageCheckJob = pin => ({ 'runs-on': images.ubuntu.arm, // Without this the two jobs race and the download fails before the check // has run — red for a reason unrelated to what it tests. @@ -66,8 +75,8 @@ export const packageCheckJob = { uses('actions/download-artifact', { name: packageArtifact }), uses('actions/setup-node', { 'node-version': node.default }), { run: installArtifact }, - { run: installCompiler }, + { run: installCompiler(pin) }, { run: enumerateDeclarations }, { run: typeCheck }, ], -} +}) diff --git a/fjs/ci/package/proof.f.mjs b/fjs/ci/package/proof.f.mjs index b726be821..933f06ab9 100644 --- a/fjs/ci/package/proof.f.mjs +++ b/fjs/ci/package/proof.f.mjs @@ -1,11 +1,16 @@ import { packageCheckJob, packageCheckJobId } from './module.f.mjs' import { packageArtifact, packageJobId } from '../node/module.f.mjs' -import { typescript } from '../config/module.f.mjs' import { assert, assertEq } from '../../asserts/module.f.mjs' +// A pin no configuration anywhere holds, so an assertion that finds it can only +// have found the value passed in. Importing the generator's own constant would +// compare it with itself and hold for any value. +const pin = /** @type {const} */ ('=1.2.3-proof') + +const job = packageCheckJob(pin) + /** @type {(fragment: string) => boolean} */ -const scriptHas = fragment => - packageCheckJob.steps.some(step => step.run?.includes(fragment) === true) +const scriptHas = fragment => job.steps.some(step => step.run?.includes(fragment) === true) export const proof = { // The defining property. With a checkout there is a tsconfig.json up the @@ -15,17 +20,17 @@ export const proof = { noCheckout: () => { assertEq(packageCheckJobId, 'package-check') assert( - !packageCheckJob.steps.some(step => step.uses?.startsWith('actions/checkout@') === true), + !job.steps.some(step => step.uses?.startsWith('actions/checkout@') === true), 'the package check must not check out the repository') }, consumesTheArtifact: () => { // Ordered after the producer: without this the two race and the // download fails before the check has run. - assertEq(packageCheckJob.needs?.[0], packageJobId) - assertEq(packageCheckJob.needs?.length, 1) + assertEq(job.needs?.[0], packageJobId) + assertEq(job.needs?.length, 1) // Downloaded by the name the producer exports, not a second literal // that can drift from it. - const download = packageCheckJob.steps.find( + const download = job.steps.find( step => step.uses?.startsWith('actions/download-artifact@') === true) assertEq(download?.with?.name, packageArtifact) }, @@ -36,10 +41,12 @@ export const proof = { assert(scriptHas('--skipLibCheck false'), 'expected skipLibCheck left false') // An empty list type-checks nothing and passes. assert(scriptHas('test -s declarations.txt'), 'expected a guard against an empty file list') - // With no checkout there is no lockfile, so the compiler comes from the - // repository's pin. An unpinned install would let the registry change - // this check's verdict with no change here. - assert(scriptHas(`"typescript@${typescript}"`), 'expected the pinned compiler') + }, + // The compiler is whatever the package pins, carried through untouched. A + // check that runs a compiler the package did not choose is a green result + // about the wrong thing. + installsTheGivenPin: () => { + assert(scriptHas(`"typescript@${pin}"`), 'expected the supplied pin installed') }, // `fjs ci` generates workflows for other projects, so the artifact's own // package name is whatever that project publishes. Installing under a fixed @@ -48,15 +55,12 @@ export const proof = { // happens to share the name instead of the artifact just built. anyPackageName: () => { assert(scriptHas('"packed@file:$(ls *.tgz)"'), 'expected the artifact installed under the fixed alias') - // More than one archive leaves it ambiguous which package is under test. - assert(scriptHas('test "$(ls *.tgz | wc -l)" -eq 1'), 'expected exactly one archive required') assert(scriptHas('find node_modules/packed'), 'expected declarations enumerated from that directory') // `tsc` splits a response file on whitespace, so an unquoted path with a // space in it fails a package that is valid. assert(scriptHas(`-printf '"%p"`), 'expected response-file paths quoted') // Every declaration form the package can ship, not just the two this - // repository happens to emit — `fjs ci` generates for projects whose - // `files` may include CommonJS declarations. + // repository happens to emit. for (const ext of /** @type {const} */ (['*.d.ts', '*.d.mts', '*.d.cts'])) { assert(scriptHas(`-name '${ext}'`), `expected ${ext} enumerated`) } diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index 8d1815c31..c29884020 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -69,9 +69,16 @@ const workflow = state => { const flake = (state, id) => text(path(state.root, ['nix', 'generated', id]), 'flake.nix') +// The packed-package check is generated only when the project pins a compiler, +// so the shared fixture supplies one. A pin no configuration holds, so an +// assertion that finds it found the value that came from here. +const runPin = /** @type {const} */ ('=9.9.9-run') + +const runPackageJson = `{"name":"other-package","devDependencies":{"typescript":"${runPin}"}}` + /** @type {(rust: boolean, nodeExtra?: (o: Os) => readonly MetaStep[]) => GitHubAction} */ const run = (rust, nodeExtra = () => []) => { - const [state, result] = virtual(makeState(rust, undefined))(ci({ nodeExtra })) + const [state, result] = virtual(makeState(rust, runPackageJson))(ci({ nodeExtra })) assertEq(exitCode(result), 0) return workflow(state) } @@ -269,6 +276,29 @@ export const proof = { gha.jobs[packageJobId]?.steps.some( step => step.uses?.startsWith('actions/upload-artifact@') === true) === true, 'expected the needed job to be the one that uploads') + // The compiler comes from the project's own package.json, not from a + // constant here that could disagree with it silently. + assert( + job.steps.some(step => step.run?.includes(`"typescript@${runPin}"`) === true), + 'expected the compiler pin read from package.json') + }, + // Without a pin the check cannot be run deterministically, so it is not + // generated at all rather than run against a compiler nobody chose. + packageCheckNeedsAPin: () => { + for (const packageJson of /** @type {const} */ ([ + undefined, // no package.json at all + 'not json', // unparseable + '"a string"', // not an object + '{"devDependencies":"x"}', // devDependencies not an object + '{"devDependencies":[]}', // nor an array + '{"devDependencies":{"typescript":1}}', // pin not a string + '{"name":"p"}', // no devDependencies + '{"name":"p","devDependencies":{}}', // no typescript + ])) { + const [state, result] = virtual(makeState(false, packageJson))(ci({ nodeExtra: () => [] })) + assertEq(exitCode(result), 0) + assertEq(workflow(state).jobs[packageCheckJobId], undefined) + } }, jobNeeds: () => { const steps = /** @type {const} */ ([{ run: 'echo hi' }]) From 7fa2575da95b17f76face09a6f98ae51965b2a09 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:01:41 +0000 Subject: [PATCH 282/370] fjs: restore @module where documentation is published #1756 stripped @module from 102 files and eebc007 from 15 private.ts, on the reading that the tag marks a package entry point. It does not: deno doc emits module_doc only for a file carrying the tag and nothing at all for one without it, so those files' module documentation was invisible. fjs/AGENTS.md section 2 was corrected in #1765; this brings the tree into line with it. Restored on 106 files, two lines each and nothing else: 89 types.ts - the public type-level API 16 private.ts - contributor-facing; 15 stripped by eebc007, plus fjs/rtti/parse/private.ts, created afterwards and never tagged 1 fjs/emergent_testing/browser.mjs The 90 restored from #1756 are byte-identical to their pre-strip originals; no file in that set had drifted since. 14 of the 15 private.ts likewise; fjs/asn.1/private.ts had gained a _Round8 type below the header, so the tag was inserted rather than the file reverted, and its header now matches the original. Left untagged deliberately: the 11 proof files, since a proof documents a verification rather than an API and no documentation build is pointed at proofs; and fjs/bnf/testlib.f.mjs, whose leading block holds only @import tags and so has no prose to attach. Verified rather than assumed. deno doc --json reports module_doc with a module tag for all 106, and for none of the controls left untagged - testlib.f.mjs and two proof files - so the check discriminates. The issue said the restore was only half of it, and the other half was that the same rule lived in three places and drifted in the copies nobody was editing. todo/migrate-typescript-to-mjs.md, a live P1, stated the old rule in three normative spots including an acceptance criterion; as written it would have re-stripped these tags. Each now links section 2 instead of restating it. The measurement record about declaration emit is history and stays as it is. fjs/todo/module-tag-restore.md is deleted, its tasks done. Section 2's paragraph marking the tree non-compliant is replaced by the proof decision it asked to have recorded there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T7vAocRuxfmWJDnujCoMup --- fjs/AGENTS.md | 11 ++- fjs/asn.1/private.ts | 2 + fjs/asn.1/types.ts | 2 + fjs/asserts/types.ts | 2 + fjs/basen/types.ts | 2 + fjs/bnf/data/private.ts | 2 + fjs/bnf/data/types.ts | 2 + fjs/bnf/descent/private.ts | 2 + fjs/bnf/descent/types.ts | 2 + fjs/bnf/ll1/private.ts | 2 + fjs/bnf/ll1/types.ts | 2 + fjs/bnf/matcher/types.ts | 2 + fjs/bnf/private.ts | 2 + fjs/bnf/token_symbol/types.ts | 2 + fjs/bnf/types.ts | 2 + fjs/cas/evo/types.ts | 2 + fjs/cas/types.ts | 2 + fjs/ci/common/types.ts | 2 + fjs/ci/nix/types.ts | 2 + fjs/ci/types.ts | 2 + fjs/cli/types.ts | 2 + fjs/common/monoid/private.ts | 2 + fjs/common/monoid/types.ts | 2 + fjs/crypto/pow/types.ts | 2 + fjs/crypto/secp/types.ts | 2 + fjs/crypto/sha2/types.ts | 2 + fjs/crypto/sign/types.ts | 2 + fjs/crypto/vdf/types.ts | 2 + fjs/dev/types.ts | 2 + fjs/djs/ast/private.ts | 2 + fjs/djs/ast/types.ts | 2 + fjs/djs/parser/private.ts | 2 + fjs/djs/parser/types.ts | 2 + fjs/djs/serializer/private.ts | 2 + fjs/djs/tokenizer/private.ts | 2 + fjs/djs/tokenizer/types.ts | 2 + fjs/djs/transpiler/types.ts | 2 + fjs/djs/types.ts | 2 + fjs/effects/list/types.ts | 2 + fjs/effects/memory/types.ts | 2 + fjs/effects/mock/types.ts | 2 + fjs/effects/node/private.ts | 2 + fjs/effects/node/types.ts | 2 + fjs/effects/node/virtual/types.ts | 2 + fjs/effects/types.ts | 2 + fjs/emergent_testing/browser.mjs | 2 + fjs/emergent_testing/types.ts | 2 + fjs/js/tokenizer/types.ts | 2 + fjs/media/html/types.ts | 2 + fjs/media/json/extended/types.ts | 2 + fjs/media/json/number/types.ts | 2 + fjs/media/json/parser/types.ts | 2 + fjs/media/json/tokenizer/types.ts | 2 + fjs/media/json/types.ts | 2 + fjs/media/nix/types.ts | 2 + fjs/media/revision/types.ts | 2 + fjs/media/type/types.ts | 2 + fjs/media/types.ts | 2 + fjs/nanvm/types.ts | 2 + fjs/protocol/json_rpc/types.ts | 2 + fjs/protocol/mcp/stdio/types.ts | 2 + fjs/protocol/mcp/types.ts | 2 + fjs/rtti/common/types.ts | 2 + fjs/rtti/data/private.ts | 2 + fjs/rtti/data/types.ts | 2 + fjs/rtti/parse/private.ts | 2 + fjs/rtti/parse/types.ts | 2 + fjs/rtti/ts/private.ts | 2 + fjs/rtti/ts/types.ts | 2 + fjs/rtti/types.ts | 2 + fjs/sul/id/types.ts | 2 + fjs/sul/level/hash/types.ts | 2 + fjs/sul/level/literal/types.ts | 2 + fjs/sul/types.ts | 2 + fjs/text/sgr/types.ts | 2 + fjs/text/types.ts | 2 + fjs/text/utf16/types.ts | 2 + fjs/text/utf8/types.ts | 2 + fjs/todo/module-tag-restore.md | 105 --------------------------- fjs/types/array/types.ts | 2 + fjs/types/bigfloat/private.ts | 2 + fjs/types/bigfloat/types.ts | 2 + fjs/types/bigint/types.ts | 2 + fjs/types/bit_vec/types.ts | 2 + fjs/types/btree/find/types.ts | 2 + fjs/types/btree/remove/private.ts | 2 + fjs/types/btree/types/types.ts | 2 + fjs/types/byte_set/types.ts | 2 + fjs/types/function/compare/types.ts | 2 + fjs/types/function/operator/types.ts | 2 + fjs/types/function/types.ts | 2 + fjs/types/list/types.ts | 2 + fjs/types/nibble_set/types.ts | 2 + fjs/types/nominal/types.ts | 2 + fjs/types/nullable/types.ts | 2 + fjs/types/object/types.ts | 2 + fjs/types/option/types.ts | 2 + fjs/types/ordered_map/types.ts | 2 + fjs/types/patricia_trie/types.ts | 2 + fjs/types/phantom/types.ts | 2 + fjs/types/prime_field/types.ts | 2 + fjs/types/range/types.ts | 2 + fjs/types/range_map/types.ts | 2 + fjs/types/result/types.ts | 2 + fjs/types/sorted_list/types.ts | 2 + fjs/types/sorted_set/types.ts | 2 + fjs/types/string_set/types.ts | 2 + fjs/types/ts/types.ts | 2 + todo/jsdoc-verification.md | 9 ++- todo/migrate-typescript-to-mjs.md | 26 ++++--- 110 files changed, 237 insertions(+), 126 deletions(-) delete mode 100644 fjs/todo/module-tag-restore.md diff --git a/fjs/AGENTS.md b/fjs/AGENTS.md index 1faa78133..9378c1b90 100644 --- a/fjs/AGENTS.md +++ b/fjs/AGENTS.md @@ -158,12 +158,11 @@ documentation build must not be pointed at it. Put it in the leading block, followed by one blank line before the first source-level import or declaration. -The tree does not obey this yet. #1756 stripped the tag from 102 files and -#1750 from 16 `private.ts`, on the older reading; the prose survives in source -and `deno doc` cannot see it. Restoring it is -[`fjs/todo/module-tag-restore.md`](./todo/module-tag-restore.md); until that -lands, an untagged `types.ts` or `private.ts` is debt rather than an example to -copy. +`proof.*` is settled rather than assumed. The restore left all 11 proof files +untagged and confirmed with `deno doc --json` that they publish no `module_doc` +— which is the intent, since a proof's prose documents a verification rather +than an API and no documentation build is pointed at proofs. Point one at them +and the tag is what would have to change. The tag is necessary, not sufficient. It decides whether `deno doc` *can* see a file's module documentation; whether anything is generated from that file is a diff --git a/fjs/asn.1/private.ts b/fjs/asn.1/private.ts index 998ccc8e4..7c7ab92ce 100644 --- a/fjs/asn.1/private.ts +++ b/fjs/asn.1/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for ASN.1 tag encoding. + * + * @module */ import type { Vec } from '../types/bit_vec/types.ts' diff --git a/fjs/asn.1/types.ts b/fjs/asn.1/types.ts index 53daddb47..98bed3594 100644 --- a/fjs/asn.1/types.ts +++ b/fjs/asn.1/types.ts @@ -1,5 +1,7 @@ /** * Types for ASN.1 BER/DER encoding and decoding over bit vectors. + * + * @module */ import type { Vec } from '../types/bit_vec/types.ts' diff --git a/fjs/asserts/types.ts b/fjs/asserts/types.ts index bfbf7ff2a..6cfbcb354 100644 --- a/fjs/asserts/types.ts +++ b/fjs/asserts/types.ts @@ -1,5 +1,7 @@ /** * Type-level assertion helpers. + * + * @module */ /** diff --git a/fjs/basen/types.ts b/fjs/basen/types.ts index e89ba87b7..a7dc081f4 100644 --- a/fjs/basen/types.ts +++ b/fjs/basen/types.ts @@ -1,5 +1,7 @@ /** * Types for the shared bit-codec factory. + * + * @module */ import type { Vec } from '../types/bit_vec/types.ts' diff --git a/fjs/bnf/data/private.ts b/fjs/bnf/data/private.ts index 144a29e11..232838eef 100644 --- a/fjs/bnf/data/private.ts +++ b/fjs/bnf/data/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the `toData` conversion. + * + * @module */ import type { Rule as FRule } from '../types.ts' diff --git a/fjs/bnf/data/types.ts b/fjs/bnf/data/types.ts index 81568f54f..9615b5b33 100644 --- a/fjs/bnf/data/types.ts +++ b/fjs/bnf/data/types.ts @@ -1,5 +1,7 @@ /** * Types for the serializable BNF intermediate representation (IR). + * + * @module */ import type { StringMap } from '../../types/object/types.ts' diff --git a/fjs/bnf/descent/private.ts b/fjs/bnf/descent/private.ts index 9d4e0c34d..b200ed5b0 100644 --- a/fjs/bnf/descent/private.ts +++ b/fjs/bnf/descent/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the recursive descent matcher backend. + * + * @module */ import type { TerminalRange } from '../types.ts' diff --git a/fjs/bnf/descent/types.ts b/fjs/bnf/descent/types.ts index 753f8a5fa..282d91453 100644 --- a/fjs/bnf/descent/types.ts +++ b/fjs/bnf/descent/types.ts @@ -5,6 +5,8 @@ * `Ast>` from [`../matcher`](../matcher). What is declared * here is what belongs to *this* backend — the metadata-carrying leaf, the * diagnostics a backtracking matcher can report, and its public result. + * + * @module */ import type { CodePoint } from '../../text/utf16/types.ts' diff --git a/fjs/bnf/ll1/private.ts b/fjs/bnf/ll1/private.ts index d8442edba..55e2d0930 100644 --- a/fjs/bnf/ll1/private.ts +++ b/fjs/bnf/ll1/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the LL(1) matcher machine. + * + * @module */ import type { CodePoint } from '../../text/utf16/types.ts' diff --git a/fjs/bnf/ll1/types.ts b/fjs/bnf/ll1/types.ts index 6834b7cce..d04b047a8 100644 --- a/fjs/bnf/ll1/types.ts +++ b/fjs/bnf/ll1/types.ts @@ -1,5 +1,7 @@ /** * Types for the LL(1) dispatch/matcher backend. + * + * @module */ import type { CodePoint } from '../../text/utf16/types.ts' diff --git a/fjs/bnf/matcher/types.ts b/fjs/bnf/matcher/types.ts index 309e072ed..671931edd 100644 --- a/fjs/bnf/matcher/types.ts +++ b/fjs/bnf/matcher/types.ts @@ -1,6 +1,8 @@ /** * Type-level API for the layer every BNF matcher backend shares: the position * it matches at, the AST it builds, and the result that pairs them. + * + * @module */ /** diff --git a/fjs/bnf/private.ts b/fjs/bnf/private.ts index 1a0246564..8b047e760 100644 --- a/fjs/bnf/private.ts +++ b/fjs/bnf/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the AST renderer in `./testlib.f.mjs`. + * + * @module */ import type { Ast } from './matcher/types.ts' diff --git a/fjs/bnf/token_symbol/types.ts b/fjs/bnf/token_symbol/types.ts index 8e5226538..6f57b1046 100644 --- a/fjs/bnf/token_symbol/types.ts +++ b/fjs/bnf/token_symbol/types.ts @@ -1,5 +1,7 @@ /** * Types for encoding multi-character token names as single BNF input symbols. + * + * @module */ import type { Nullable } from '../../types/nullable/types.ts' diff --git a/fjs/bnf/types.ts b/fjs/bnf/types.ts index f8d10b9f5..c219dbd3b 100644 --- a/fjs/bnf/types.ts +++ b/fjs/bnf/types.ts @@ -1,5 +1,7 @@ /** * Type-level API for BNF grammar primitives and helpers. + * + * @module */ import type { StringMap } from '../types/object/types.ts' diff --git a/fjs/cas/evo/types.ts b/fjs/cas/evo/types.ts index 4432b922d..45d2c4f46 100644 --- a/fjs/cas/evo/types.ts +++ b/fjs/cas/evo/types.ts @@ -1,6 +1,8 @@ /** * Type-level API for `fjs/cas/evo/module.f.mjs`: the Evo cache shape and the * `Evo` API surface it builds. + * + * @module */ import type { Effect, NotImplemented, Operation } from '../../effects/types.ts' diff --git a/fjs/cas/types.ts b/fjs/cas/types.ts index 4b9532443..9c0c86e50 100644 --- a/fjs/cas/types.ts +++ b/fjs/cas/types.ts @@ -1,5 +1,7 @@ /** * Types for content-addressable storage utilities. + * + * @module */ import type { Vec } from '../types/bit_vec/types.ts' diff --git a/fjs/ci/common/types.ts b/fjs/ci/common/types.ts index c560de766..70c307258 100644 --- a/fjs/ci/common/types.ts +++ b/fjs/ci/common/types.ts @@ -1,6 +1,8 @@ /** * Type-level API for shared CI types: GitHub Actions step/job RTTI schemas, * the `MetaStep` representation used by tool-specific modules. + * + * @module */ import type { Ts } from '../../rtti/ts/types.ts' diff --git a/fjs/ci/nix/types.ts b/fjs/ci/nix/types.ts index b8d665fc0..7b36f2cd1 100644 --- a/fjs/ci/nix/types.ts +++ b/fjs/ci/nix/types.ts @@ -1,5 +1,7 @@ /** * Types for generated CI Nix flakes. + * + * @module */ /** A CI job's development environment, one generated flake each. */ diff --git a/fjs/ci/types.ts b/fjs/ci/types.ts index 1ec1ff39b..26840f278 100644 --- a/fjs/ci/types.ts +++ b/fjs/ci/types.ts @@ -1,5 +1,7 @@ /** * Types for the CI workflow generator. + * + * @module */ import type { MetaStep, Os } from './common/types.ts' diff --git a/fjs/cli/types.ts b/fjs/cli/types.ts index 60a181853..c980d9e3d 100644 --- a/fjs/cli/types.ts +++ b/fjs/cli/types.ts @@ -1,5 +1,7 @@ /** * Types for the CLI command dispatch table. + * + * @module */ import type { NodeOp, Program } from '../effects/node/types.ts' diff --git a/fjs/common/monoid/private.ts b/fjs/common/monoid/private.ts index a86d8a890..ffbc1adf9 100644 --- a/fjs/common/monoid/private.ts +++ b/fjs/common/monoid/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the monoid fold. + * + * @module */ /** diff --git a/fjs/common/monoid/types.ts b/fjs/common/monoid/types.ts index e08cedf66..ebb449151 100644 --- a/fjs/common/monoid/types.ts +++ b/fjs/common/monoid/types.ts @@ -1,5 +1,7 @@ /** * The `Monoid` algebraic structure. + * + * @module */ import type { Reduce } from '../../types/function/operator/types.ts' diff --git a/fjs/crypto/pow/types.ts b/fjs/crypto/pow/types.ts index a7f9aa4ae..7de8b37f0 100644 --- a/fjs/crypto/pow/types.ts +++ b/fjs/crypto/pow/types.ts @@ -1,5 +1,7 @@ /** * Types for Bitcoin-style proof-of-work verification. + * + * @module */ import type { Vec } from '../../types/bit_vec/types.ts' diff --git a/fjs/crypto/secp/types.ts b/fjs/crypto/secp/types.ts index e501c0988..33e5aaff4 100644 --- a/fjs/crypto/secp/types.ts +++ b/fjs/crypto/secp/types.ts @@ -1,5 +1,7 @@ /** * Types for short Weierstrass elliptic-curve arithmetic over a prime field. + * + * @module */ import type { Fold, Reduce } from '../../types/function/operator/types.ts' diff --git a/fjs/crypto/sha2/types.ts b/fjs/crypto/sha2/types.ts index 48c04dfd6..c5986537c 100644 --- a/fjs/crypto/sha2/types.ts +++ b/fjs/crypto/sha2/types.ts @@ -1,5 +1,7 @@ /** * Types for the SHA-2 family of hash functions. + * + * @module */ import type { Tuple } from '../../types/array/types.ts' diff --git a/fjs/crypto/sign/types.ts b/fjs/crypto/sign/types.ts index 9a3fc6f36..22f23da2c 100644 --- a/fjs/crypto/sign/types.ts +++ b/fjs/crypto/sign/types.ts @@ -1,5 +1,7 @@ /** * Type-level API for signing helpers built on secp256k1 and SHA-256 primitives. + * + * @module */ import type { Tuple } from '../../types/array/types.ts' diff --git a/fjs/crypto/vdf/types.ts b/fjs/crypto/vdf/types.ts index e737239c0..9ccbbd294 100644 --- a/fjs/crypto/vdf/types.ts +++ b/fjs/crypto/vdf/types.ts @@ -1,5 +1,7 @@ /** * Types for the Sloth verifiable delay function. + * + * @module */ import type { Nullable } from '../../types/nullable/types.ts' diff --git a/fjs/dev/types.ts b/fjs/dev/types.ts index faf54fe94..106350f1d 100644 --- a/fjs/dev/types.ts +++ b/fjs/dev/types.ts @@ -1,5 +1,7 @@ /** * Types for indexing modules and loading FunctionalScript files. + * + * @module */ import type { StringMap } from '../types/object/types.ts' diff --git a/fjs/djs/ast/private.ts b/fjs/djs/ast/private.ts index 6c5e7ce49..68b28227a 100644 --- a/fjs/djs/ast/private.ts +++ b/fjs/djs/ast/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the DJS AST evaluator. + * + * @module */ import type { List } from '../../types/list/types.ts' diff --git a/fjs/djs/ast/types.ts b/fjs/djs/ast/types.ts index bc95df71a..8225d6fda 100644 --- a/fjs/djs/ast/types.ts +++ b/fjs/djs/ast/types.ts @@ -2,6 +2,8 @@ * Type-level API for `fjs/djs/ast/module.f.mjs`: the AST shape `run` * evaluates — `AstModule`, `AstConst`, `AstModuleRef`, `AstArray`, * `AstObject`, and `AstBody`. + * + * @module */ import type { Primitive } from '../types.ts' diff --git a/fjs/djs/parser/private.ts b/fjs/djs/parser/private.ts index 567963e37..7649cb761 100644 --- a/fjs/djs/parser/private.ts +++ b/fjs/djs/parser/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the DJS parser. + * + * @module */ import type { CodePointMeta } from '../../bnf/descent/types.ts' diff --git a/fjs/djs/parser/types.ts b/fjs/djs/parser/types.ts index 822c57105..51fdf4146 100644 --- a/fjs/djs/parser/types.ts +++ b/fjs/djs/parser/types.ts @@ -2,6 +2,8 @@ * Type-level API for `fjs/djs/parser/module.f.mjs`: the `ParseError` shape * `parseFromTokens` reports, the `_ValueToken` subset `tokenToValue` accepts, * and the parser layer's token alphabet. + * + * @module */ import type { TokenMetadata } from '../../js/tokenizer/types.ts' diff --git a/fjs/djs/serializer/private.ts b/fjs/djs/serializer/private.ts index ee8cfb80e..a461610dd 100644 --- a/fjs/djs/serializer/private.ts +++ b/fjs/djs/serializer/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the DJS serializer. + * + * @module */ import type { List } from '../../types/list/types.ts' diff --git a/fjs/djs/tokenizer/private.ts b/fjs/djs/tokenizer/private.ts index b2d184327..b836d4878 100644 --- a/fjs/djs/tokenizer/private.ts +++ b/fjs/djs/tokenizer/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the DJS tokenizer. + * + * @module */ import type { CodePointMeta } from '../../bnf/descent/types.ts' diff --git a/fjs/djs/tokenizer/types.ts b/fjs/djs/tokenizer/types.ts index c27f9580f..74e3b7783 100644 --- a/fjs/djs/tokenizer/types.ts +++ b/fjs/djs/tokenizer/types.ts @@ -1,6 +1,8 @@ /** * Type-level API for `fjs/djs/tokenizer/module.f.mjs`: the DJS token shapes * `tokenize`/`tokenizeJs`/`tokenizeString` produce. + * + * @module */ import type { diff --git a/fjs/djs/transpiler/types.ts b/fjs/djs/transpiler/types.ts index 90c8da5b3..d6b77c546 100644 --- a/fjs/djs/transpiler/types.ts +++ b/fjs/djs/transpiler/types.ts @@ -1,5 +1,7 @@ /** * Types for the DJS transpiler. + * + * @module */ import type { Unknown } from '../types.ts' diff --git a/fjs/djs/types.ts b/fjs/djs/types.ts index daec81213..0a7845fd1 100644 --- a/fjs/djs/types.ts +++ b/fjs/djs/types.ts @@ -1,6 +1,8 @@ /** * DJS's own value model: `Primitive`, `Unknown`, `Object`, and `Array`, * layered on top of JSON's `Primitive` with `bigint` and `undefined` added. + * + * @module */ import type { diff --git a/fjs/effects/list/types.ts b/fjs/effects/list/types.ts index 9ac174033..d0bf671ee 100644 --- a/fjs/effects/list/types.ts +++ b/fjs/effects/list/types.ts @@ -1,5 +1,7 @@ /** * Types for the effectful cons-list. + * + * @module */ import type { Operation } from '../types.ts' diff --git a/fjs/effects/memory/types.ts b/fjs/effects/memory/types.ts index f5a741165..9d3fb2834 100644 --- a/fjs/effects/memory/types.ts +++ b/fjs/effects/memory/types.ts @@ -1,5 +1,7 @@ /** * Types for typed key-value memory effects. + * + * @module */ import type { Phantom } from '../../types/phantom/types.ts' diff --git a/fjs/effects/mock/types.ts b/fjs/effects/mock/types.ts index 72977b076..500b8d3a7 100644 --- a/fjs/effects/mock/types.ts +++ b/fjs/effects/mock/types.ts @@ -1,5 +1,7 @@ /** * Types for mock effect runtimes. + * + * @module */ import type { Result } from "../../types/result/types.ts" diff --git a/fjs/effects/node/private.ts b/fjs/effects/node/private.ts index bc88d25af..d3d942a74 100644 --- a/fjs/effects/node/private.ts +++ b/fjs/effects/node/private.ts @@ -2,6 +2,8 @@ * Implementation-private types for the Node.js effect runner: the narrowed * structural views of `node:http` objects the runner interprets HTTP * operations against. + * + * @module */ import type { StringMap } from '../../types/object/types.ts' diff --git a/fjs/effects/node/types.ts b/fjs/effects/node/types.ts index 067816eef..ee9fe58ec 100644 --- a/fjs/effects/node/types.ts +++ b/fjs/effects/node/types.ts @@ -1,5 +1,7 @@ /** * Types for Node.js effect operations. + * + * @module */ import type { List as EffectList } from '../../types/list/types.ts' diff --git a/fjs/effects/node/virtual/types.ts b/fjs/effects/node/virtual/types.ts index cd010d2dd..c690cdf5f 100644 --- a/fjs/effects/node/virtual/types.ts +++ b/fjs/effects/node/virtual/types.ts @@ -1,6 +1,8 @@ /** * Types for the virtual Node-effect operations used by filesystem and * process tests. + * + * @module */ import type { Vec } from '../../../types/bit_vec/types.ts' diff --git a/fjs/effects/types.ts b/fjs/effects/types.ts index d06187dc0..974a02db1 100644 --- a/fjs/effects/types.ts +++ b/fjs/effects/types.ts @@ -1,5 +1,7 @@ /** * Types for the core effect system. + * + * @module */ import type { Ok, Error, Result } from '../types/result/types.ts' diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index fe5d781b7..1963cc7e3 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -13,6 +13,8 @@ * iframe therefore renders into that frame, and a proof can drive the module * with a stand-in root. * + * @module + * * @import { BrowserTestReport, TestResult, _BrowserImporter, _BrowserTestResult, _TestAndPath } from './types.ts' * @import { Result } from '../types/result/types.ts' */ diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index cd56f7150..5191ae631 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -1,5 +1,7 @@ /** * Types for running and reporting FunctionalScript tests. + * + * @module */ import type { Effect, Operation } from '../effects/types.ts' diff --git a/fjs/js/tokenizer/types.ts b/fjs/js/tokenizer/types.ts index 2c05a14aa..6f06df20e 100644 --- a/fjs/js/tokenizer/types.ts +++ b/fjs/js/tokenizer/types.ts @@ -1,5 +1,7 @@ /** * Types for the JavaScript tokenizer. + * + * @module */ import type { RangeMapArray } from '../../types/range_map/types.ts' diff --git a/fjs/media/html/types.ts b/fjs/media/html/types.ts index 71610fff6..dc39fc837 100644 --- a/fjs/media/html/types.ts +++ b/fjs/media/html/types.ts @@ -1,5 +1,7 @@ /** * Type-level API for HTML serialization. + * + * @module */ import type { StringMap } from '../../types/object/types.ts' diff --git a/fjs/media/json/extended/types.ts b/fjs/media/json/extended/types.ts index 186d90b45..6aeb45028 100644 --- a/fjs/media/json/extended/types.ts +++ b/fjs/media/json/extended/types.ts @@ -5,6 +5,8 @@ * This is a runtime representation, not a new syntax: an extended value's * serialized form is ordinary valid JSON text, with no `123n` literal, tagged * object, or quoted-integer convention. + * + * @module */ import type { Primitive as JsonPrimitive, Tree, TreeObject, TreeArray, TreeMapEntries } from '../types.ts' diff --git a/fjs/media/json/number/types.ts b/fjs/media/json/number/types.ts index fcf556e47..986930e5d 100644 --- a/fjs/media/json/number/types.ts +++ b/fjs/media/json/number/types.ts @@ -1,5 +1,7 @@ /** * Types for the lexical view of a JSON number token. + * + * @module */ /** diff --git a/fjs/media/json/parser/types.ts b/fjs/media/json/parser/types.ts index 993a0e533..00d2560b3 100644 --- a/fjs/media/json/parser/types.ts +++ b/fjs/media/json/parser/types.ts @@ -1,6 +1,8 @@ /** * Types for the shared structural JSON parser: its numeric policy, the tree * that policy produces, and the parser's internal state. + * + * @module */ import type { Tree } from '../types.ts' diff --git a/fjs/media/json/tokenizer/types.ts b/fjs/media/json/tokenizer/types.ts index cf5463f18..48bb9f187 100644 --- a/fjs/media/json/tokenizer/types.ts +++ b/fjs/media/json/tokenizer/types.ts @@ -1,5 +1,7 @@ /** * Types for the JSON tokenizer. + * + * @module */ import type { StringToken, NumberToken, ErrorToken, EofToken, JsTokenWithMetadata } from '../../../js/tokenizer/types.ts' diff --git a/fjs/media/json/types.ts b/fjs/media/json/types.ts index 116bdc91b..2ea9c9b3f 100644 --- a/fjs/media/json/types.ts +++ b/fjs/media/json/types.ts @@ -8,6 +8,8 @@ * `Assert>`. The pin is what keeps the two * descriptions of the same data model from drifting apart — and it holds the * `Tree` spelling to the same data model the schemas describe. + * + * @module */ import type { Entry as ObjectEntry } from '../../types/object/types.ts' diff --git a/fjs/media/nix/types.ts b/fjs/media/nix/types.ts index 3f2041035..b0c37d762 100644 --- a/fjs/media/nix/types.ts +++ b/fjs/media/nix/types.ts @@ -1,5 +1,7 @@ /** * Type-level API for the Nix expression eDSL. + * + * @module */ type _Identifier = string diff --git a/fjs/media/revision/types.ts b/fjs/media/revision/types.ts index 480609ac5..cfaa6edc0 100644 --- a/fjs/media/revision/types.ts +++ b/fjs/media/revision/types.ts @@ -11,6 +11,8 @@ * the schema side of the same recursion: `lock` cannot infer its own type * (a `const` may not reference itself in its own initializer), so it carries * this named annotation instead. + * + * @module */ import type { Ts } from '../../rtti/ts/types.ts' diff --git a/fjs/media/type/types.ts b/fjs/media/type/types.ts index ce763d2f7..26d8923ee 100644 --- a/fjs/media/type/types.ts +++ b/fjs/media/type/types.ts @@ -1,5 +1,7 @@ /** * Types for magic-byte MIME type detection. + * + * @module */ import type { Nullable } from '../../types/nullable/types.ts' diff --git a/fjs/media/types.ts b/fjs/media/types.ts index 4a066f01a..337e5114e 100644 --- a/fjs/media/types.ts +++ b/fjs/media/types.ts @@ -1,6 +1,8 @@ /** * Type-level API for `fjs/media/module.f.mjs`: the `DialectEntry` registry * shape `detect` and `dialectEntry` share with every registered dialect. + * + * @module */ import type { Unknown } from '../rtti/ts/types.ts' diff --git a/fjs/nanvm/types.ts b/fjs/nanvm/types.ts index 921243f6b..0f4f963a2 100644 --- a/fjs/nanvm/types.ts +++ b/fjs/nanvm/types.ts @@ -5,6 +5,8 @@ * behaviour: [`proof.f.mjs`](./proof.f.mjs) runs it against a standard * JavaScript engine, and [`rust/module.f.mjs`](./rust/module.f.mjs) prints it * as the Rust tests in [`test/generated.rs`](./test/generated.rs). + * + * @module */ /** diff --git a/fjs/protocol/json_rpc/types.ts b/fjs/protocol/json_rpc/types.ts index f4e064db6..a9b825b8d 100644 --- a/fjs/protocol/json_rpc/types.ts +++ b/fjs/protocol/json_rpc/types.ts @@ -2,6 +2,8 @@ * Type-level API for `fjs/protocol/json_rpc/module.f.mjs`: `Id`, `Request`, * `RpcError`, and `Response`, derived from the module's own rtti schemas, * plus the `Handler` / `Handlers` shapes a dispatcher is built from. + * + * @module */ import type { Unknown } from '../../media/json/types.ts' diff --git a/fjs/protocol/mcp/stdio/types.ts b/fjs/protocol/mcp/stdio/types.ts index 110d785d8..ed613f1c2 100644 --- a/fjs/protocol/mcp/stdio/types.ts +++ b/fjs/protocol/mcp/stdio/types.ts @@ -1,5 +1,7 @@ /** * Types for the stdio transport of JSON-RPC / MCP servers. + * + * @module */ import type { Unknown } from '../../../media/json/types.ts' diff --git a/fjs/protocol/mcp/types.ts b/fjs/protocol/mcp/types.ts index 71866f5ca..b0bdea80d 100644 --- a/fjs/protocol/mcp/types.ts +++ b/fjs/protocol/mcp/types.ts @@ -2,6 +2,8 @@ * Type-level API for `fjs/protocol/mcp/module.f.mjs`: the MCP message * schemas' derived types, plus the `McpHandlers`/`ToolEntry`/`Handle`/ * session-state shapes `mcpStep` is built from. + * + * @module */ import type { Ts } from '../../rtti/ts/types.ts' diff --git a/fjs/rtti/common/types.ts b/fjs/rtti/common/types.ts index db29ddcce..7c1079e5e 100644 --- a/fjs/rtti/common/types.ts +++ b/fjs/rtti/common/types.ts @@ -1,5 +1,7 @@ /** * Type-level API shared by RTTI consumers (`validate`, `parse`). + * + * @module */ import type { Primitive, Unknown } from '../ts/types.ts' diff --git a/fjs/rtti/data/private.ts b/fjs/rtti/data/private.ts index 641509001..0233224fa 100644 --- a/fjs/rtti/data/private.ts +++ b/fjs/rtti/data/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the RTTI data conversion. + * + * @module */ import type { StringMap } from '../../types/object/types.ts' diff --git a/fjs/rtti/data/types.ts b/fjs/rtti/data/types.ts index bcee0fca5..70239abcd 100644 --- a/fjs/rtti/data/types.ts +++ b/fjs/rtti/data/types.ts @@ -6,6 +6,8 @@ * `undefined`, `false`, `true`), numbers, strings, bigints, arrays and * objects — so that union, equality and subset reduce to kind-wise set * operations. See `./README.md` for the design rationale. + * + * @module */ import type { StringMap } from '../../types/object/types.ts' diff --git a/fjs/rtti/parse/private.ts b/fjs/rtti/parse/private.ts index 4a72d1ef8..e2d03108b 100644 --- a/fjs/rtti/parse/private.ts +++ b/fjs/rtti/parse/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the RTTI parser's container rebuilds. + * + * @module */ import type { Presence } from '../common/types.ts' diff --git a/fjs/rtti/parse/types.ts b/fjs/rtti/parse/types.ts index 995f63e86..0e9892271 100644 --- a/fjs/rtti/parse/types.ts +++ b/fjs/rtti/parse/types.ts @@ -1,5 +1,7 @@ /** * Type-level API for RTTI deserialization. + * + * @module */ import type { Type } from '../types.ts' diff --git a/fjs/rtti/ts/private.ts b/fjs/rtti/ts/private.ts index 64c9f4305..e3891a577 100644 --- a/fjs/rtti/ts/private.ts +++ b/fjs/rtti/ts/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the RTTI-to-TypeScript printer. + * + * @module */ import type { Printer } from '../../types/ts/types.ts' diff --git a/fjs/rtti/ts/types.ts b/fjs/rtti/ts/types.ts index 07a8aa4c1..ce99fc017 100644 --- a/fjs/rtti/ts/types.ts +++ b/fjs/rtti/ts/types.ts @@ -6,6 +6,8 @@ * * The runtime `toTs` function (`printer` in `./module.f.mjs`) mirrors `Ts` at value * level, returning a TypeScript type expression string for a given RTTI schema. + * + * @module */ import type { And, Equal } from '../../types/ts/types.ts' diff --git a/fjs/rtti/types.ts b/fjs/rtti/types.ts index 3588ed166..e250f2185 100644 --- a/fjs/rtti/types.ts +++ b/fjs/rtti/types.ts @@ -44,6 +44,8 @@ * ## Converting to TypeScript types * * See `./ts/module.f.ts` for `Ts` and the `*Ts` transformer types. + * + * @module */ import type { Assert } from '../asserts/types.ts' diff --git a/fjs/sul/id/types.ts b/fjs/sul/id/types.ts index e622b70dc..8dc4f68f6 100644 --- a/fjs/sul/id/types.ts +++ b/fjs/sul/id/types.ts @@ -1,5 +1,7 @@ /** * Type-level API for SUL identifiers. + * + * @module */ import type { Nominal } from '../../types/nominal/types.ts' diff --git a/fjs/sul/level/hash/types.ts b/fjs/sul/level/hash/types.ts index 07576fc66..a780d86ac 100644 --- a/fjs/sul/level/hash/types.ts +++ b/fjs/sul/level/hash/types.ts @@ -1,5 +1,7 @@ /** * Type-level API for hash-level SUL encoding. + * + * @module */ import type { State } from '../../../types/patricia_trie/types.ts' diff --git a/fjs/sul/level/literal/types.ts b/fjs/sul/level/literal/types.ts index 600920283..f011fb448 100644 --- a/fjs/sul/level/literal/types.ts +++ b/fjs/sul/level/literal/types.ts @@ -1,5 +1,7 @@ /** * Type-level API for the literal SUL level encoding. + * + * @module */ import type { Vec } from '../../../types/bit_vec/types.ts' diff --git a/fjs/sul/types.ts b/fjs/sul/types.ts index 0a151089d..23f709822 100644 --- a/fjs/sul/types.ts +++ b/fjs/sul/types.ts @@ -1,5 +1,7 @@ /** * Type-level API for the full SUL streaming encoder. + * + * @module */ import type { InternalState } from '../types/patricia_trie/types.ts' diff --git a/fjs/text/sgr/types.ts b/fjs/text/sgr/types.ts index cde570e93..464666916 100644 --- a/fjs/text/sgr/types.ts +++ b/fjs/text/sgr/types.ts @@ -1,5 +1,7 @@ /** * Types for ANSI CSI/SGR terminal output helpers. + * + * @module */ export type Stdout = { diff --git a/fjs/text/types.ts b/fjs/text/types.ts index 1ec1b48f6..43e126b26 100644 --- a/fjs/text/types.ts +++ b/fjs/text/types.ts @@ -1,5 +1,7 @@ /** * Types for indented text blocks and UTF-8 bit vectors. + * + * @module */ import type { Vec } from '../types/bit_vec/types.ts' diff --git a/fjs/text/utf16/types.ts b/fjs/text/utf16/types.ts index e5e53d745..5ca45fdbd 100644 --- a/fjs/text/utf16/types.ts +++ b/fjs/text/utf16/types.ts @@ -1,5 +1,7 @@ /** * Types for UTF-16 code units and Unicode code points. + * + * @module */ /** diff --git a/fjs/text/utf8/types.ts b/fjs/text/utf8/types.ts index f7db66b5a..5767645b3 100644 --- a/fjs/text/utf8/types.ts +++ b/fjs/text/utf8/types.ts @@ -1,5 +1,7 @@ /** * Types for UTF-8 byte-level encoding and decoding. + * + * @module */ import type { Tuple } from '../../types/array/types.ts' diff --git a/fjs/todo/module-tag-restore.md b/fjs/todo/module-tag-restore.md deleted file mode 100644 index b9daa1010..000000000 --- a/fjs/todo/module-tag-restore.md +++ /dev/null @@ -1,105 +0,0 @@ -## module-tag-restore. Put `@module` back where documentation is published - -**Priority:** P3 -**Status:** open - -### Problem - -[#1756](https://github.com/functionalscript/functionalscript/pull/1756) stripped -`@module` from 102 files, on the reading that the tag marks a package entry -point. That reading was wrong. The tag is what makes a leading JSDoc block *be* -module documentation: `deno doc` emits `module_doc` for a file carrying it and -nothing at all for a file without it — the block is dropped, not demoted. -Verified against the pinned Deno, for `.mjs` and `.ts` alike. - -98 of those 102 files had real prose in the block, and -[#1750](https://github.com/functionalscript/functionalscript/pull/1750) did the -same to 16 `private.ts` on the same reading — all 16 have prose and none carry -the tag. Their module documentation is still in the source and `deno doc` cannot -see it. Nothing is broken at runtime. - -The tag is necessary, not sufficient. -[`../website/todo/publish-deno-doc-to-website.md`](../website/todo/publish-deno-doc-to-website.md) -currently plans `deno doc --html **/module.f.mjs`, a glob that excludes every -`types.ts` and `private.ts`, so restoring the tag alone would not put these -descriptions on the website. Restoring it is what makes them *available* to be -read at all; where they are then shown is a separate decision, and it is not the -same decision for the two file kinds. - -**`types.ts` yes, `private.ts` no.** `types.ts` is the public type-level API, so -widening that glob to reach it belongs to the website issue. `private.ts` holds -implementation-private types outside the public declaration closure, and -[`separate-private-types.md`](./separate-private-types.md) plans to drop its -generated declarations from the package in Stage 2 — putting them on the public -API site would publish exactly what that design removes. Its prose is worth the -tag for contributors reading the sources or running `deno doc` themselves; it is -not website input. - -[`../AGENTS.md`](../AGENTS.md) §2 now states the rule correctly — the tag goes -wherever a file has module-level documentation a reader is meant to get from -`deno doc`, whoever that reader is. This issue is the tree catching up. - -### Proposal - -Four groups, and the first two are mechanical. - -**1. Restore — 89 `types.ts`, all with prose.** Put `@module` back in the -leading block. `types.ts` is the entry point of the type-level API and its -emitted declarations are what a package consumer reads, so this is squarely -documentation a reader is meant to get. The exact text is recoverable per file: - -```sh -git show 0233904^: -``` - -**2. Restore — 16 `private.ts`, all with prose.** Stripped by #1750 rather than -#1756, so they are not in the 102, but the same reading and the same fix. -`../AGENTS.md` §2 names `private.ts` alongside `types.ts`, and now says why the -audience is not the same one: the tag makes the prose reachable by `deno doc` for -a contributor, and the public site must stay pointed away from these files. Do -not carry this group into the website glob. - -**3. Leave — 11 proof files** (8 with prose, 3 with a bare tag). Proof -documentation is not published, so by the rule the tag has nothing to attach to, -and `../AGENTS.md` §1.2's proof example now shows a block without it. Worth -confirming rather than assuming: if `deno doc` is ever pointed at proofs, the -answer flips. The three bare-tag ones lost nothing either way. - -**4. Judge individually — two files that are neither.** - -- `fjs/bnf/testlib.f.mjs` — its block held only `@import` tags, no prose. Under - the rule there is nothing to attach, so it wants no tag. Nothing to restore. -- `fjs/emergent_testing/browser.mjs` — real prose ("Browser-native proof - execution and report rendering", and why it has no Node dependencies). It is - a published module in the package, so it reads like group 1. - -### Tasks - -- [ ] Restore the tag in the 89 `types.ts` files. -- [ ] Restore it in the 16 `private.ts` files, without adding them to any public - documentation build. -- [ ] Restore `fjs/emergent_testing/browser.mjs`; leave `fjs/bnf/testlib.f.mjs`. -- [ ] Confirm the proof decision, and record it in - [`../AGENTS.md`](../AGENTS.md) §2 rather than only here. -- [ ] Correct the copy of the old rule in - [`../../todo/migrate-typescript-to-mjs.md`](../../todo/migrate-typescript-to-mjs.md) - ("Module header and import ordering"), which still states the tag belongs - only to an entry point — it was restated there rather than linked, so it - did not move when §2 did. -- [ ] Drop §2's paragraph saying the tree does not obey the rule yet, once it - does. -- [ ] Spot-check with `deno doc --json` on a restored file that `module_doc` - comes back, rather than trusting the edit. - -### Related - -- [`../AGENTS.md`](../AGENTS.md) §2 — the rule, and why the tag exists. -- [`../website/todo/publish-deno-doc-to-website.md`](../website/todo/publish-deno-doc-to-website.md) - — the other half for group 1: its `**/module.f.mjs` glob would have to widen to - `types.ts` before those descriptions reach a website reader. Not to - `private.ts`. -- [`separate-private-types.md`](./separate-private-types.md) — why `private.ts` - is contributor-facing only, and why Stage 2 drops its declarations from the - package. -- [`../../todo/jsdoc-verification.md`](../../todo/jsdoc-verification.md) — how a - rule like this might be checked at all, which is why it drifted twice unnoticed. diff --git a/fjs/types/array/types.ts b/fjs/types/array/types.ts index e704c019e..368cd9b14 100644 --- a/fjs/types/array/types.ts +++ b/fjs/types/array/types.ts @@ -1,5 +1,7 @@ /** * Types for JavaScript immutable arrays. + * + * @module */ import type { Assert } from '../../asserts/types.ts' diff --git a/fjs/types/bigfloat/private.ts b/fjs/types/bigfloat/private.ts index d0dd82c1d..41fa94f95 100644 --- a/fjs/types/bigfloat/private.ts +++ b/fjs/types/bigfloat/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for the big-float module. + * + * @module */ import type { BigFloat } from './types.ts' diff --git a/fjs/types/bigfloat/types.ts b/fjs/types/bigfloat/types.ts index 2f4538ba6..5c2bd3794 100644 --- a/fjs/types/bigfloat/types.ts +++ b/fjs/types/bigfloat/types.ts @@ -1,5 +1,7 @@ /** * Types for big-floats built from bigint mantissa and exponent parts. + * + * @module */ export type BigFloat = readonly [bigint, number] diff --git a/fjs/types/bigint/types.ts b/fjs/types/bigint/types.ts index 85a44e014..359da1f23 100644 --- a/fjs/types/bigint/types.ts +++ b/fjs/types/bigint/types.ts @@ -1,5 +1,7 @@ /** * Operator types specialized to `bigint`. + * + * @module */ import type { diff --git a/fjs/types/bit_vec/types.ts b/fjs/types/bit_vec/types.ts index 6f50e98ac..58c940a51 100644 --- a/fjs/types/bit_vec/types.ts +++ b/fjs/types/bit_vec/types.ts @@ -1,5 +1,7 @@ /** * Types for bit vectors normalized on the most-significant bit. + * + * @module */ import type { Sign } from '../function/compare/types.ts' diff --git a/fjs/types/btree/find/types.ts b/fjs/types/btree/find/types.ts index 436aebb6d..e688e5cd1 100644 --- a/fjs/types/btree/find/types.ts +++ b/fjs/types/btree/find/types.ts @@ -1,5 +1,7 @@ /** * Types for B-tree lookup results and paths. + * + * @module */ import type { Index } from '../../array/types.ts' diff --git a/fjs/types/btree/remove/private.ts b/fjs/types/btree/remove/private.ts index 1c1d8152f..0f60b6e34 100644 --- a/fjs/types/btree/remove/private.ts +++ b/fjs/types/btree/remove/private.ts @@ -1,5 +1,7 @@ /** * Implementation-private types for B-tree removal. + * + * @module */ import type { Branch1, Branch3, Branch5, Leaf1 } from '../types/types.ts' diff --git a/fjs/types/btree/types/types.ts b/fjs/types/btree/types/types.ts index 0b8f3adeb..d16c93e71 100644 --- a/fjs/types/btree/types/types.ts +++ b/fjs/types/btree/types/types.ts @@ -1,5 +1,7 @@ /** * Shared type definitions for persistent B-tree modules. + * + * @module */ import type { Tuple } from '../../array/types.ts' diff --git a/fjs/types/byte_set/types.ts b/fjs/types/byte_set/types.ts index 624632cda..b43392007 100644 --- a/fjs/types/byte_set/types.ts +++ b/fjs/types/byte_set/types.ts @@ -1,5 +1,7 @@ /** * Type-level API for the byte-set module. + * + * @module */ export type ByteSet = bigint diff --git a/fjs/types/function/compare/types.ts b/fjs/types/function/compare/types.ts index 90568d2fd..dec10fca7 100644 --- a/fjs/types/function/compare/types.ts +++ b/fjs/types/function/compare/types.ts @@ -1,5 +1,7 @@ /** * Comparison function types. + * + * @module */ export type Sign = -1 | 0 | 1 diff --git a/fjs/types/function/operator/types.ts b/fjs/types/function/operator/types.ts index c2a02a7f6..74bd26616 100644 --- a/fjs/types/function/operator/types.ts +++ b/fjs/types/function/operator/types.ts @@ -1,5 +1,7 @@ /** * Common higher-order operator type aliases. + * + * @module */ export type Binary = (a: A) => (b: B) => R diff --git a/fjs/types/function/types.ts b/fjs/types/function/types.ts index b445235ed..652102337 100644 --- a/fjs/types/function/types.ts +++ b/fjs/types/function/types.ts @@ -1,5 +1,7 @@ /** * Types for function composition. + * + * @module */ /** diff --git a/fjs/types/list/types.ts b/fjs/types/list/types.ts index 843b29524..a564eca58 100644 --- a/fjs/types/list/types.ts +++ b/fjs/types/list/types.ts @@ -1,5 +1,7 @@ /** * Types for the immutable list data structure. + * + * @module */ import type { Nullable } from '../nullable/types.ts' diff --git a/fjs/types/nibble_set/types.ts b/fjs/types/nibble_set/types.ts index 5ebf9638a..e83e40de7 100644 --- a/fjs/types/nibble_set/types.ts +++ b/fjs/types/nibble_set/types.ts @@ -1,5 +1,7 @@ /** * Types for compact 4-bit membership tracking. + * + * @module */ /** A set of nibbles as a 16-bit mask. JSON-serializable. */ diff --git a/fjs/types/nominal/types.ts b/fjs/types/nominal/types.ts index 7d55b1a42..53bd6813d 100644 --- a/fjs/types/nominal/types.ts +++ b/fjs/types/nominal/types.ts @@ -1,5 +1,7 @@ /** * Types for nominal typing (branded TypeScript types). + * + * @module */ /** diff --git a/fjs/types/nullable/types.ts b/fjs/types/nullable/types.ts index 1708517d6..81b3ce6a2 100644 --- a/fjs/types/nullable/types.ts +++ b/fjs/types/nullable/types.ts @@ -1,5 +1,7 @@ /** * Types for nullable (`null`) value handling. + * + * @module */ export type Nullable = T | null diff --git a/fjs/types/object/types.ts b/fjs/types/object/types.ts index 9ef88cb95..4d0c3d0d9 100644 --- a/fjs/types/object/types.ts +++ b/fjs/types/object/types.ts @@ -2,6 +2,8 @@ * Types for plain-object helpers: the `OptionalMap`/`RequiredMap`/`StringMap` * record shapes and `Entry`, and the `OneKey`/`SingleProperty`/`NotUnion` * utility types. + * + * @module */ /** A record over the keys of `K`, each value possibly missing at runtime. */ diff --git a/fjs/types/option/types.ts b/fjs/types/option/types.ts index 3ffb4c49f..fc7937afb 100644 --- a/fjs/types/option/types.ts +++ b/fjs/types/option/types.ts @@ -1,5 +1,7 @@ /** * Optional tuple-based value representation. + * + * @module */ /** diff --git a/fjs/types/ordered_map/types.ts b/fjs/types/ordered_map/types.ts index 0a248baef..22e15922e 100644 --- a/fjs/types/ordered_map/types.ts +++ b/fjs/types/ordered_map/types.ts @@ -1,5 +1,7 @@ /** * Types for the ordered map data structure. + * + * @module */ import type { Tree } from '../btree/types/types.ts' diff --git a/fjs/types/patricia_trie/types.ts b/fjs/types/patricia_trie/types.ts index f93715aa8..cb36cf4e1 100644 --- a/fjs/types/patricia_trie/types.ts +++ b/fjs/types/patricia_trie/types.ts @@ -1,5 +1,7 @@ /** * Types for the streaming Patricia trie. + * + * @module */ /** diff --git a/fjs/types/phantom/types.ts b/fjs/types/phantom/types.ts index 3aac11b2f..ed13afe80 100644 --- a/fjs/types/phantom/types.ts +++ b/fjs/types/phantom/types.ts @@ -5,6 +5,8 @@ * The phantom field uses a unique symbol key so it is excluded from string index * signatures (`{ readonly [K in string]: ... }`), making `Phantom` valid * for any `S` regardless of its index signature constraints. + * + * @module */ declare const phantomKey: unique symbol diff --git a/fjs/types/prime_field/types.ts b/fjs/types/prime_field/types.ts index 01df29dfd..e09c3b81b 100644 --- a/fjs/types/prime_field/types.ts +++ b/fjs/types/prime_field/types.ts @@ -1,5 +1,7 @@ /** * Types for prime field arithmetic over `bigint`. + * + * @module */ import type { Reduce, Unary } from '../bigint/types.ts' diff --git a/fjs/types/range/types.ts b/fjs/types/range/types.ts index 5bf8a6ac0..cc0da8f6c 100644 --- a/fjs/types/range/types.ts +++ b/fjs/types/range/types.ts @@ -1,5 +1,7 @@ /** * Range and interval types for numeric boundaries. + * + * @module */ export type Range = readonly [number, number] diff --git a/fjs/types/range_map/types.ts b/fjs/types/range_map/types.ts index ae61dd929..d60b59b24 100644 --- a/fjs/types/range_map/types.ts +++ b/fjs/types/range_map/types.ts @@ -1,5 +1,7 @@ /** * Types for managing and merging range maps. + * + * @module */ import type { Equal, Reduce } from '../function/operator/types.ts' diff --git a/fjs/types/result/types.ts b/fjs/types/result/types.ts index 6878093d4..3a03999b8 100644 --- a/fjs/types/result/types.ts +++ b/fjs/types/result/types.ts @@ -1,5 +1,7 @@ /** * Types for representing operations that can succeed or fail. + * + * @module */ /** diff --git a/fjs/types/sorted_list/types.ts b/fjs/types/sorted_list/types.ts index 49a558f60..bad600921 100644 --- a/fjs/types/sorted_list/types.ts +++ b/fjs/types/sorted_list/types.ts @@ -1,5 +1,7 @@ /** * Types for sorted immutable lists and their merge operations. + * + * @module */ import type { Sign } from '../function/compare/types.ts' diff --git a/fjs/types/sorted_set/types.ts b/fjs/types/sorted_set/types.ts index 2e7abfd75..f2583cc3d 100644 --- a/fjs/types/sorted_set/types.ts +++ b/fjs/types/sorted_set/types.ts @@ -1,5 +1,7 @@ /** * Type-level API for the sorted-set module. + * + * @module */ export type SortedSet = readonly T[] diff --git a/fjs/types/string_set/types.ts b/fjs/types/string_set/types.ts index 75488433a..1cb27e79f 100644 --- a/fjs/types/string_set/types.ts +++ b/fjs/types/string_set/types.ts @@ -1,5 +1,7 @@ /** * Type-level API for the string-set module. + * + * @module */ import type { Tree } from '../btree/types/types.ts' diff --git a/fjs/types/ts/types.ts b/fjs/types/ts/types.ts index df1622df6..cf5cb4f8c 100644 --- a/fjs/types/ts/types.ts +++ b/fjs/types/ts/types.ts @@ -1,6 +1,8 @@ /** * Types for the TypeScript source emitter: the `Equal` compile-time predicate * and the `Printer` interface. + * + * @module */ import type { Assert } from '../../asserts/types.ts' diff --git a/todo/jsdoc-verification.md b/todo/jsdoc-verification.md index 2547b1044..92f279457 100644 --- a/todo/jsdoc-verification.md +++ b/todo/jsdoc-verification.md @@ -14,7 +14,9 @@ absent from a file that needs one all type-check clean. The consequences are not hypothetical. `@module` drifted onto 102 files against the documented rule, was stripped from all of them on a misreading of *why* the rule existed, and the misreading survived a merge because nothing could tell the -difference. That is three passes over the same tag with no signal at any point. +difference — then a fourth pass put the tag back on 106 files. Four passes over +one tag, and no check registered any of them; each was caught, if at all, by a +person reading prose. The obvious repair is not available. Root [`AGENTS.md` §6](../AGENTS.md#6-external-tools) rules out approximating this with a text @@ -67,7 +69,6 @@ is worth doing when a tool makes it cheap, not worth building a tool for. - [`../AGENTS.md`](../AGENTS.md#6-external-tools) §6 — why not a text pattern, and that a real tool needs approval first. - [`../fjs/AGENTS.md`](../fjs/AGENTS.md) §2 — the `@module` and `@import` rules - this would check. -- [`../fjs/todo/module-tag-restore.md`](../fjs/todo/module-tag-restore.md) — the - drift this issue exists because of. + this would check. The restore that brought the tree into line with §2 is the + fourth pass over this one tag; no check registered any of the four. - [`eslint.md`](./eslint.md) — the standing ESLint discussion. diff --git a/todo/migrate-typescript-to-mjs.md b/todo/migrate-typescript-to-mjs.md index 6d29eb8bf..9ae6b41d1 100644 --- a/todo/migrate-typescript-to-mjs.md +++ b/todo/migrate-typescript-to-mjs.md @@ -295,9 +295,10 @@ Use `@template out T`, `@template in T`, or constrained forms such as A JavaScript implementation must not gain a real JavaScript import just because it uses a separately declared type. Use JSDoc `@import` with the same real source path used by `import type`. All module-level `@import` tags belong in one -leading JSDoc block — sharing it with `@module` in a `module.*` file, or -standing alone at the top of a `proof.*` or other non-`module.*` file, which -does not carry `@module`; do not create separate `@import` comment blocks. +leading JSDoc block — sharing it with `@module` in a file that carries one, or +standing alone in a file that does not, such as `proof.*`; do not create +separate `@import` comment blocks. Which files carry `@module` is +[`fjs/AGENTS.md`](../fjs/AGENTS.md) §2, not this document. The corresponding TypeScript implementation uses `import type` with the same specifier: @@ -318,8 +319,8 @@ JavaScript in a `module.*` file uses: */ ``` -JavaScript in a `proof.*` file (or any other non-`module.*` file, which has no -`@module` tag) groups the same `@import` tags without one: +JavaScript in a `proof.*` file, which has no `@module` tag, groups the same +`@import` tags without one: ```js /** @@ -457,10 +458,13 @@ source meanwhile. #### Module header and import ordering -The `@module` tag belongs only to a package's entry-point file — `module.f.mjs` / -`module.mjs`. It is not required on `proof.f.mjs`, `types.ts`, or any other file. -A `module.*` file starts with one leading JSDoc block carrying `@module`; always -put one blank line after that block before the first source-level import or +`@module` placement is [`fjs/AGENTS.md`](../fjs/AGENTS.md) §2: the tag goes +wherever a file has module-level documentation a reader is meant to get from +`deno doc`, `types.ts` and `private.ts` included — not only `module.*`. It is +linked rather than restated here deliberately; this document carried its own +copy of an earlier, narrower rule and so did not move when §2 did. A `module.*` +file starts with one leading JSDoc block carrying `@module`; always put one +blank line after that block before the first source-level import or declaration. For TypeScript, put type-only imports first, external or built-in runtime imports @@ -1199,8 +1203,8 @@ person can re-check rather than re-derive. Counts are as of `types.ts` may preserve declaration documentation through normal TypeScript emit. - Every module-level import follows the module-header/import convention: - `@module` appears only on `module.*` entry-point files, never on `proof.*` or - other files; JavaScript groups module-level `@import` tags into one leading + `@module` placement follows [`fjs/AGENTS.md`](../fjs/AGENTS.md) §2; + JavaScript groups module-level `@import` tags into one leading JSDoc block — shared with `@module` where present, standing alone otherwise — one blank line follows that block, external/built-in runtime imports form their own group, and repository-owned relative runtime imports are ordered as From 635d1372bb00bcd15c5ae5a11d75b251d71166d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:01:52 +0000 Subject: [PATCH 283/370] fsc: delete the dead JSON/FunctionalScript BNF grammar Stage 2 of todo/parser-serializer-restructure.md. fjs/fsc/bnf.f.mjs and fjs/fsc/json.f.mjs had no importer outside each other, no proof coverage, and no place in the coverage gate (which includes **/module.f.mjs only), so nothing checked them. The JSON half duplicated `deterministic` in fjs/bnf/testlib.f.mjs rule for rule, and fjs/fsc is the compiler rather than a media format, so it was the wrong home regardless. Deleted rather than salvaged into a proof-covered fjs/bnf example: the FunctionalScript half encodes newline-separated statements (`fjsTail = option(['\n', ws0, fjs])`, `wsNoNewLine0`), the design the merged plan replaces with `;`, so keeping it would have preserved a grammar that contradicts the decision record. Git history holds the id/alpha/comment rules if a later stage wants them. The three citations in fjs/bnf/todo/207-bnf-semantic-actions.md now point at fjs/bnf/testlib.f.mjs, noting that it spells `character` and `member` inline; bnf-grammar-single-owner records that its two-copy inventory is complete because the third copy is gone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- fjs/bnf/todo/207-bnf-semantic-actions.md | 13 +- fjs/fsc/bnf.f.mjs | 80 ----------- fjs/fsc/json.f.mjs | 127 ------------------ fjs/fsc/todo/orphaned-json-grammar.md | 41 ------ .../json/todo/bnf-grammar-single-owner.md | 7 + todo/parser-serializer-restructure.md | 31 +++-- 6 files changed, 34 insertions(+), 265 deletions(-) delete mode 100644 fjs/fsc/bnf.f.mjs delete mode 100644 fjs/fsc/json.f.mjs delete mode 100644 fjs/fsc/todo/orphaned-json-grammar.md diff --git a/fjs/bnf/todo/207-bnf-semantic-actions.md b/fjs/bnf/todo/207-bnf-semantic-actions.md index 372c976e5..d03129517 100644 --- a/fjs/bnf/todo/207-bnf-semantic-actions.md +++ b/fjs/bnf/todo/207-bnf-semantic-actions.md @@ -357,7 +357,7 @@ const optD = option(digit) // Option ``` **Hypothesis 2 — real cyclic grammars cannot stay unannotated. ❌** -Reproducing the JSON shape from `fjs/fsc/json.f.mjs` *without* the `: Rule` +Reproducing the JSON shape from `fjs/bnf/testlib.f.mjs` *without* the `: Rule` annotations fails to compile: ```ts @@ -603,8 +603,10 @@ and §5.5 is what hangs on it. ### 6. Worked example: JSON -Using the grammar from `fjs/fsc/json.f.mjs` (`character`, `escape`, `string`, -`member`, `object`, …) and the positional elision model: +Using a JSON grammar whose rules are named as below (`character`, `escape`, +`string`, `member`, `object`, …) — the shape of `deterministic` in +`fjs/bnf/testlib.f.mjs`, which spells `character` and `member` inline — and the +positional elision model: ```ts // escape: { '"' | '\\' | '/' | 'b'|'f'|'n'|'r'|'t' | u: ['u',h,h,h,h] } @@ -717,4 +719,7 @@ a JSON action set exist as the first real consumer. exported; the predicate the instantiation-time boundary check (§5.3) depends on — no longer a blocker — and relevant if schemas are auto-derived from the BNF data form. -- `fjs/fsc/json.f.mjs`, `fjs/bnf/testlib.f.mjs` — the grammars used in §6. +- `fjs/bnf/testlib.f.mjs` — the `deterministic` grammar used in §6. A third + copy of it lived at `fjs/fsc/json.f.mjs` until it was deleted as dead code; + do not restore it, see + [parser-serializer-restructure](../../../todo/parser-serializer-restructure.md). diff --git a/fjs/fsc/bnf.f.mjs b/fjs/fsc/bnf.f.mjs deleted file mode 100644 index 25455ca5e..000000000 --- a/fjs/fsc/bnf.f.mjs +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @import { Rule } from '../bnf/types.ts' - */ - -import { range, remove, set, option } from '../bnf/module.f.mjs' -import { digit, json, unicode, ws0, ws1, wsNoNewLine0 } from './json.f.mjs' - -/** @type {Rule} */ -export const wsModule = () => [ws0, module] - -/** @type {Rule} */ -const module = () => ({ - json: [json, ws0], - fjs, -}) - -/** @type {Rule} */ -const fjs = () => option({ - const: ['const', ws1, id, ws0, '=', ws0, json, wsNoNewLine0, fjsTail], - export: ['export', ws1, 'default', ws1, json], -}) - -/** @type {Rule} */ -const fjsTail = option(['\n', ws0, fjs]) - -// line comment - -/** @type {Rule} */ -const lineItem = remove(unicode, set('\n')) - -/** @type {Rule} */ -const line = () => option([lineItem, line]) - -/** @type {Rule} */ -const lineComment = () => ['/', commentTail, '\n'] - -/** @type {Rule} */ -const multiLineSkip = remove(unicode, set('/')) - -/** @type {Rule} */ -const multiLineItem = remove(unicode, set('*')) - -/** @type {Rule} */ -const multiLine = () => ({ - '*': ['*', multiLineTail], - '_': [multiLineItem, multiLine] -}) - -/** @type {Rule} */ -const multiLineTail = { - '/': '/', - '_': [multiLineSkip, multiLine] -} - -/** @type {Rule} */ -const commentTail = { - '/': ['/', lineComment], - '*': ['*', multiLine], -} - -// id - -/** @type {Rule} */ -const id = () => [alpha, idTail0] - -/** @type {Rule} */ -const alpha = { - upper: range('AZ'), - lower: range('az'), - _: set('_$'), -} - -/** @type {Rule} */ -const idTail0 = () => option([alphaDigit, idTail0]) - -/** @type {Rule} */ -const alphaDigit = { - alpha, - digit, -} diff --git a/fjs/fsc/json.f.mjs b/fjs/fsc/json.f.mjs deleted file mode 100644 index d87cfcbec..000000000 --- a/fjs/fsc/json.f.mjs +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @import { Rule, TerminalRange } from '../bnf/types.ts' - */ - -import { join0Plus, rangeEncode, range, remove, repeat0Plus, set, option } from '../bnf/module.f.mjs' - -// space - -/** @type {Rule} */ -const wsNoNewLineItem = set(' \t\r') - -/** @type {Rule} */ -export const wsNoNewLine0 = repeat0Plus(wsNoNewLineItem) - -/** @type {Rule} */ -const wsItem = { - wsNoNewLineItem, - n: '\n', -} - -/** @type {Rule} */ -export const ws0 = () => option(ws1) - -/** @type {Rule} */ -export const ws1 = [wsItem, ws0] - -// - -/** @type {Rule} */ -export const json = () => ({ - object, - array, - number, - string, - true: 'true', - false: 'false', - null: 'null', -}) - -// - -/** @type {Rule} */ -const separator = [',', ws0] - -// object - -/** @type {Rule} */ -const member = () => [string, ws0, ':', ws0, json, ws0] - -/** @type {Rule} */ -const object = ['{', ws0, join0Plus(member, separator), '}'] - -// array - -/** @type {Rule} */ -const element = [json, ws0] - -/** @type {Rule} */ -const array = ['[', ws0, join0Plus(element, separator), ']'] - -// string - -/** @type {Rule} */ -const character = () => ({ - ...remove(unicode, set('"\\')), - '\\': ['\\', escape], -}) - -/** @type {Rule} */ -const string = ['"', repeat0Plus(character), '"'] - -/** @type {TerminalRange} */ -export const unicode = rangeEncode(0x20, 0x10FFFF) - -/** @type {Rule} */ -const escape = () => ({ - ...set('"\\/bfnrt'), - 'u': ['u', hex, hex, hex, hex] // 117 -}) - -/** @type {Rule} */ -const hex = () => ({ - digit, - upper: range('AF'), - lower: range('af'), -}) - -// number - -/** @type {Rule} */ -const number = () => ({ - uNumber, - minus: ['-', uNumber], -}) - -/** @type {Rule} */ -const uNumber = () => [uint, fraction0, exponent0] - -/** @type {Rule} */ -const uint = () => ({ - '0': '0', - '19': [range('19'), digits0] -}) - -/** @type {Rule} */ -export const digit = range('09') - -/** @type {Rule} */ -const digits0 = repeat0Plus(digit) - -/** @type {Rule} */ -const digits1 = [digit, digits0] - -/** @type {Rule} */ -const fraction0 = option(['.', digits1]) - -/** @type {Rule} */ -const exponent0 = () => option([e, sign, digits1]) - -/** @type {Rule} */ -const e = set('eE') - -/** @type {Rule} */ -const sign = option({ - '+': '+', - '-': '-', -}) diff --git a/fjs/fsc/todo/orphaned-json-grammar.md b/fjs/fsc/todo/orphaned-json-grammar.md deleted file mode 100644 index f50745b20..000000000 --- a/fjs/fsc/todo/orphaned-json-grammar.md +++ /dev/null @@ -1,41 +0,0 @@ -## A third JSON grammar copy is dead code - -**Priority:** P3 -**Status:** open - -### Problem - -`fjs/fsc/json.f.mjs` (125 lines) is a complete JSON grammar written with -`fjs/bnf` combinators — `string`/`character`/`escape`/`hex`, -`number`/`uint`/`fraction0`/`exponent0`, `object`/`array`/`member`, -`ws0`/`ws1` — duplicating `deterministic` in `fjs/bnf/testlib.f.mjs:136-196` -rule for rule. - -[bnf-grammar-single-owner](../../media/json/todo/bnf-grammar-single-owner.md) -inventories the JSON grammar as existing in exactly two places -(`fjs/bnf/testlib` and `fjs/djs/tokenizer`); this third copy is not in that -inventory, so implementing the todo as written would strand it. - -It is also dead: `fjs/fsc/bnf.f.mjs` is the only importer of `json.f.mjs`, -and nothing imports `bnf.f.mjs` (`wsModule` has zero consumers). Neither file -has proof coverage — `fjs/fsc/proof.f.mjs` imports only `./module.f.mjs`. And -`fjs/fsc` is the compiler, not a media format, so the JSON half is in the -wrong module regardless. - -### Proposal - -Either delete both files, or keep only `bnf.f.mjs`'s genuinely -FunctionalScript-specific rules (`fjs`, `lineComment`, `multiLine`, -`id`/`alpha`) and have them import the JSON half from the future -`fjs/media/json` grammar owner. Either way, add this pair to -`bnf-grammar-single-owner`'s inventory. - -### Tasks - -- [ ] Decide: delete, or rebase on the shared JSON grammar -- [ ] Update `bnf-grammar-single-owner`'s inventory and task list - -### Related - -- [bnf-grammar-single-owner](../../media/json/todo/bnf-grammar-single-owner.md) - — the two-copy inventory this pair is missing from diff --git a/fjs/media/json/todo/bnf-grammar-single-owner.md b/fjs/media/json/todo/bnf-grammar-single-owner.md index 18a57642c..620092d24 100644 --- a/fjs/media/json/todo/bnf-grammar-single-owner.md +++ b/fjs/media/json/todo/bnf-grammar-single-owner.md @@ -14,6 +14,9 @@ places, and neither copy is owned by `fjs/media/json`: - `fjs/djs/tokenizer/module.f.mjs` restates much of the JSON lexical grammar and extends it for DJS. +Two, not three: a dead third copy at `fjs/fsc/json.f.mjs` was deleted rather +than given an owner, so this inventory is complete as written. + The duplicated digit/string rules have no single owner, while `fjs/bnf` itself should remain grammar tooling rather than the home of a concrete media grammar. @@ -131,3 +134,7 @@ Before implementing this TODO after the blocking split: ownership of the lexical BNF grammar. - [group-fs-subdirectories-by-concern](../../../todo/group-fs-subdirectories-by-concern.md) — media-directory ownership convention followed by this placement. +- [parser-serializer-restructure](../../../../todo/parser-serializer-restructure.md) + — the plan this task now sits inside; its stage 2 deleted the third copy, and + its BNF rule (grammars are spec text plus proof-covered `fjs/bnf` examples, + never a runtime dependency of the codecs) constrains where this one can land. diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index ff30698c2..495c68ac5 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -22,9 +22,8 @@ relationships grew rather than being designed: `fjs compile`. It conflates two different things: a data interchange format (values, `const` sharing) and the language front end (imports, comments, identifier keys, future expressions). -- **`fjs/fsc`** — nearly empty: a character-classifier stub plus a dead third - copy of the JSON grammar - ([orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md)). +- **`fjs/fsc`** — nearly empty: a character-classifier stub. It also held a + dead third copy of the JSON grammar, deleted by stage 2. - **`fjs/bnf`** — the grammar toolkit, still evolving: a breaking EOF-encoding change shipped recently ([#1516](https://github.com/functionalscript/functionalscript/pull/1516)), @@ -73,9 +72,9 @@ fjs/fsc JS tokenizer (comments, all evolves with the language - **BNF is not a runtime dependency of the media codecs.** The spec carries the grammars as BNF text; `fjs/bnf/**` may hold the JSON and DataJS grammars as *proof-covered examples* cross-checked against the spec's test vectors. - An example grammar without proof coverage is how - [orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md) happened; - none may be added without proofs. + An example grammar without proof coverage is how the dead `fjs/fsc` copy + happened — nothing imported or proved it, so it silently drifted from the + other two; none may be added without proofs. ### The DataJS format (decision record) @@ -242,9 +241,14 @@ throughout. vectors (accept, reject, round-trip) that every later stage runs against. Decides the one remaining deferred detail: the media type. (The canonical layout is decided: one line — see **Serialization** above.) -2. **Dead code** — delete `fjs/fsc/bnf.f.mjs` and `fjs/fsc/json.f.mjs`, or - convert the salvageable parts into proof-covered `fjs/bnf/**` examples. - Resolves [orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md). +2. **Dead code — done.** `fjs/fsc/bnf.f.mjs` and `fjs/fsc/json.f.mjs` are + deleted rather than salvaged: both were dead (no importer) and unproven, + the JSON half duplicated `deterministic` in `fjs/bnf/testlib.f.mjs` rule + for rule, and the FunctionalScript half encoded **newline-separated** + statements (`fjsTail = option(['\n', ws0, fjs])`, `wsNoNewLine0`) — the + design this plan replaces with `;`, so keeping it would have preserved a + grammar contradicting the decision record above. Git history holds them if + a future stage wants the `id`/`alpha`/comment rules. 3. **JSON self-contained tokenizer** — replace the `fjs/js/tokenizer` wrapper in `fjs/media/json/tokenizer` with a scanner of JSON's own lexical grammar, exporting the string and number scanners for reuse. @@ -311,8 +315,9 @@ throughout. - [ ] Stage 1: write `spec/datajs/` and the conformance vectors; file its co-located todo. -- [ ] Stage 2: resolve - [orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md). +- [x] Stage 2: dead `fjs/fsc` grammar deleted; its todo file removed and the + citations in [207](../fjs/bnf/todo/207-bnf-semantic-actions.md) + repointed at `fjs/bnf/testlib.f.mjs`. - [ ] Stage 3: JSON self-contained tokenizer; file its todo under `fjs/media/json/todo/`. - [ ] Stage 4: `fjs/media/datajs`; file its todo. @@ -343,8 +348,8 @@ throughout. stage 4's `fjs/media/datajs`; its special-number round-trip requirement is satisfied by the DataJS spec rather than DJS-specific patches. -- [orphaned-json-grammar](../fjs/fsc/todo/orphaned-json-grammar.md) — - resolved by stage 2. +- `orphaned-json-grammar` — **done**: resolved by stage 2 and its file + deleted with the code it described. - `fjs/djs/README.md` and the remaining `fjs/djs/todo/*` files — move with their subject matter in stage 5; the DJS name in them refers to the moved front end, not to DataJS. From a53e6b89136c83e79aab02e307f2b3fc7a834099 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:03:59 +0000 Subject: [PATCH 284/370] changelog: entry for the fsc dead-grammar removal Named by the pull request number, per changelog/README.md. The removal takes files that ship in the package (files: ["**/*.mjs"], no exports map), so it is a public-API removal and carries the BREAKING CHANGES prefix even though nothing in the repository imported them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- changelog/unreleased/1768.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/unreleased/1768.md diff --git a/changelog/unreleased/1768.md b/changelog/unreleased/1768.md new file mode 100644 index 000000000..f99cd0163 --- /dev/null +++ b/changelog/unreleased/1768.md @@ -0,0 +1,4 @@ +- **BREAKING CHANGES:** `fjs/fsc`: the unused `bnf.f.mjs` and `json.f.mjs` BNF + grammars are removed. They had no importer, no proof coverage, and the JSON + half duplicated `deterministic` in `fjs/bnf/testlib.f.mjs`; an importer of + `functionalscript/fjs/fsc/json.f.mjs` should use that instead. From 9f6da874e5bf5b51b6a16eae46ac7bc1913d2a1c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:07:25 +0000 Subject: [PATCH 285/370] ci: one command per step, and paths as arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two commands shared a step for no reason: `npm init` and `npm install` are separate things, and the split exists so a failure names what failed. Every step is now one command, six in all. The response file is gone with it. `find -print0` and `xargs -0` hand the paths to tsc as arguments, so a space or a quote in a path survives without quoting or escaping — review found both cases, and this removes the mechanism rather than patching it twice. The empty-list guard stays as its own step: tsc does exit non-zero on no arguments, but by printing usage, which says nothing about why. The compiler pin must now be exact. A range such as ^7.0.0 lets a later registry release change the verdict with no change here, which is the one thing running without a checkout is meant to prevent. Relaxing the pin drops the job from the generated workflow — a visible diff in review rather than a quiet loss of checking. Verified on the emitted steps: 396 declarations and TypeScript 7.0.2, a directory named `we"ird dir` with both a space and a quote now passes where the response file would have split it, and a dangling private import still fails the type-check step. 3480/3480, coverage 100%, round-trip clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 12 +++++-- fjs/ci/module.f.mjs | 7 +++- fjs/ci/package/module.f.mjs | 68 ++++++++++++++++--------------------- fjs/ci/package/proof.f.mjs | 9 ++--- fjs/ci/proof.f.mjs | 2 ++ 5 files changed, 52 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7314bc6a..b5ae37ef6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -539,16 +539,22 @@ } }, { - "run": "npm init -y > /dev/null\nnpm install \"packed@file:$(ls *.tgz)\"" + "run": "npm init -y > /dev/null" + }, + { + "run": "npm install \"packed@file:$(ls *.tgz)\"" }, { "run": "npm install \"typescript@=7.0.2\"" }, { - "run": "find node_modules/packed \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) -printf '\"%p\"\\n' > declarations.txt\ntest -s declarations.txt" + "run": "find node_modules/packed \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) -print0 > declarations" + }, + { + "run": "test -s declarations" }, { - "run": "npx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt" + "run": "xargs -0 npx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false < declarations" } ] }, diff --git a/fjs/ci/module.f.mjs b/fjs/ci/module.f.mjs index 3ebd8a6ad..d4f4a83fd 100644 --- a/fjs/ci/module.f.mjs +++ b/fjs/ci/module.f.mjs @@ -84,7 +84,12 @@ const compilerPin = text => { const dev = root.devDependencies if (typeof dev !== 'object' || dev === null || dev instanceof Array) { return undefined } const pin = dev.typescript - return typeof pin === 'string' ? pin : undefined + // Only an exact pin. A range such as `^7.0.0` lets a later registry release + // change this check's verdict with no change here, which is the one thing + // running it without a checkout is meant to prevent. Relaxing the pin drops + // the job from the generated workflow, which is a visible diff rather than + // a quiet loss of checking. + return typeof pin === 'string' && pin.startsWith('=') ? pin : undefined } /** @type {(setup: Setup) => Effect} */ diff --git a/fjs/ci/package/module.f.mjs b/fjs/ci/package/module.f.mjs index 36a5ff320..2b6ae154c 100644 --- a/fjs/ci/package/module.f.mjs +++ b/fjs/ci/package/module.f.mjs @@ -13,57 +13,52 @@ import { packageArtifact, packageJobId } from '../node/module.f.mjs' export const packageCheckJobId = /** @type {const} */ ('package-check') -// Deliberately not built through `toSteps`: that helper injects -// `actions/checkout`, and the missing checkout is this job's whole point. With -// no repository on the runner there is no `tsconfig.json` up the tree to -// inherit, no `node_modules` to resolve into, and no source file that could -// stand in for a declaration the tarball omits — so the job can only see what -// a real consumer sees. -// One step per stage, so a failure names the stage that failed instead of -// arriving as one opaque script. - -// A fixed alias, so every later step names the package literally. The +// A fixed alias, so every later command names the package literally. The // artifact's own name would otherwise have to be derived and carried between -// steps, and `fjs ci` generates workflows for projects whose package is not -// this one. The narrow case an alias gives up: a package that imports itself by +// steps. The narrow case an alias gives up: a package that imports itself by // name — legal once `exports` is declared — does not resolve under a different -// directory name, so such a package would fail a check a real consumer passes. -// Nothing here self-references; revisit this if that changes. +// directory name. Nothing here self-references; revisit if that changes. const alias = /** @type {const} */ ('packed') -// Installed under a fixed alias so every later step names the package -// literally. Two archives would make npm's spec malformed and fail loudly, so -// nothing here guards a count. -const installArtifact = /** @type {const} */ (`npm init -y > /dev/null -npm install "${alias}@file:$(ls *.tgz)"`) +const declarations = /** @type {const} */ ('declarations') /** + * One command per step, so a failure names what failed rather than arriving as + * an opaque script. + * * The compiler is whatever the project pins, passed through untouched. With no * checkout there is no lockfile, so a version chosen here instead would let the * registry — or a constant that drifted from `package.json` — decide the * verdict. * - * @type {(pin: string) => string} + * @type {(pin: string) => readonly string[]} */ -const installCompiler = pin => `npm install "typescript@${pin}"` - -// Every declaration the package ships, enumerated from the installed artifact: -// a hand-written import list cannot see a module that gains a private type -// module later, which is the case this check exists to catch. An empty list -// would type-check nothing and pass. Each path is quoted because `tsc` splits a -// response file on whitespace, so a directory with a space in its name would -// otherwise fail a package that is valid. -const enumerateDeclarations = /** @type {const} */ (`find node_modules/${alias} \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) -printf '"%p"\\n' > declarations.txt -test -s declarations.txt`) - -// skipLibCheck stays at its false default: it is what makes tsc open these -// declarations and report a reference the tarball does not carry. -const typeCheck = /** @type {const} */ (`npx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false @declarations.txt`) +const commands = pin => [ + 'npm init -y > /dev/null', + `npm install "${alias}@file:$(ls *.tgz)"`, + `npm install "typescript@${pin}"`, + // `-print0` rather than a text list: the paths reach `tsc` as arguments, so + // a space or a quote in one survives without quoting or escaping. + `find node_modules/${alias} \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) -print0 > ${declarations}`, + // An empty list would type-check nothing and pass. `tsc` does exit non-zero + // on no arguments, but by printing usage, which says nothing about why. + `test -s ${declarations}`, + // skipLibCheck stays at its false default: it is what makes tsc open these + // declarations and report a reference the tarball does not carry. + `xargs -0 npx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false < ${declarations}`, +] /** * Downloads the packed tarball, installs it as a real dependency, and * type-checks every declaration it ships with the compiler the package pins. * + * Deliberately not built through `toSteps`: that helper injects + * `actions/checkout`, and the missing checkout is this job's whole point. With + * no repository on the runner there is no `tsconfig.json` up the tree to + * inherit, no `node_modules` to resolve into, and no source file that could + * stand in for a declaration the tarball omits — so the job can only see what a + * real consumer sees. + * * @type {(pin: string) => Job} */ export const packageCheckJob = pin => ({ @@ -74,9 +69,6 @@ export const packageCheckJob = pin => ({ steps: [ uses('actions/download-artifact', { name: packageArtifact }), uses('actions/setup-node', { 'node-version': node.default }), - { run: installArtifact }, - { run: installCompiler(pin) }, - { run: enumerateDeclarations }, - { run: typeCheck }, + ...commands(pin).map(run => ({ run })), ], }) diff --git a/fjs/ci/package/proof.f.mjs b/fjs/ci/package/proof.f.mjs index 933f06ab9..358c2fe12 100644 --- a/fjs/ci/package/proof.f.mjs +++ b/fjs/ci/package/proof.f.mjs @@ -40,7 +40,7 @@ export const proof = { // `true` stops the checking without saying so. assert(scriptHas('--skipLibCheck false'), 'expected skipLibCheck left false') // An empty list type-checks nothing and passes. - assert(scriptHas('test -s declarations.txt'), 'expected a guard against an empty file list') + assert(scriptHas('test -s declarations'), 'expected a guard against an empty file list') }, // The compiler is whatever the package pins, carried through untouched. A // check that runs a compiler the package did not choose is a green result @@ -56,9 +56,10 @@ export const proof = { anyPackageName: () => { assert(scriptHas('"packed@file:$(ls *.tgz)"'), 'expected the artifact installed under the fixed alias') assert(scriptHas('find node_modules/packed'), 'expected declarations enumerated from that directory') - // `tsc` splits a response file on whitespace, so an unquoted path with a - // space in it fails a package that is valid. - assert(scriptHas(`-printf '"%p"`), 'expected response-file paths quoted') + // Paths reach tsc as arguments, so a space or a quote in one needs no + // quoting or escaping to survive. + assert(scriptHas('-print0'), 'expected NUL-separated paths') + assert(scriptHas('xargs -0'), 'expected the paths passed as arguments') // Every declaration form the package can ship, not just the two this // repository happens to emit. for (const ext of /** @type {const} */ (['*.d.ts', '*.d.mts', '*.d.cts'])) { diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index c29884020..faa8eddbb 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -294,6 +294,8 @@ export const proof = { '{"devDependencies":{"typescript":1}}', // pin not a string '{"name":"p"}', // no devDependencies '{"name":"p","devDependencies":{}}', // no typescript + '{"devDependencies":{"typescript":"^7.0.0"}}', // a range, not a pin + '{"devDependencies":{"typescript":"7.0.2"}}', // bare, still not exact ])) { const [state, result] = virtual(makeState(false, packageJson))(ci({ nodeExtra: () => [] })) assertEq(exitCode(result), 0) From 652ec49ed6d6869958a04544ee6d5af8d00cc0f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:08:42 +0000 Subject: [PATCH 286/370] changelog: the check needs an exact pin The entry promised the job unconditionally, which stopped being true when the pin had to be exact for the job to be generated. Review caught the mismatch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- changelog/unreleased/1767.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog/unreleased/1767.md b/changelog/unreleased/1767.md index 6aa224027..62c61f739 100644 --- a/changelog/unreleased/1767.md +++ b/changelog/unreleased/1767.md @@ -1,4 +1,4 @@ -- `fjs ci` now generates a `package-check` job: it downloads the packed tarball - uploaded by the Node job, installs it as a dependency outside any checkout, - and type-checks every declaration the package ships with the compiler version - the package itself pins. +- `fjs ci` now generates a `package-check` job for a project that pins + TypeScript exactly: it downloads the packed tarball uploaded by the Node job, + installs it as a dependency outside any checkout, and type-checks every + declaration the package ships with that pinned compiler. From 20b4043b73d1ad9a8123f1d055e5278b870e677a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:12:56 +0000 Subject: [PATCH 287/370] rtti: settle absence before reading any member The presence pass recorded each declared member's presence before the bound, but an illegal absence was still rejected inside the reading walk, in declaration order. So the arm that is too short recursed into every member ahead of the missing one first -- and those are the operands the longer arm shares, which is the exponential the gate exists to remove, just reached from a value the short arm has to reject rather than accept. Measured on a chain of the three-element dot form with a leaf no arm accepts: 2.5s at depth 16, doubling per level. Now 15ms at depth 400. Valid chains are unaffected -- 16ms at depth 400, as before. Both readers answer absence in one pass over the recorded flags, before the reading walk, which then only records what it was handed. Acceptance is unchanged (0 differences over the 1550-pair differential against main) and the two readers still report identical error paths; an absent required member now reports its own position rather than an earlier member's failure, pinned in both proof suites. Reported by Codex on the pull request. --- changelog/unreleased/1766.md | 8 ++++---- fjs/rtti/parse/module.f.mjs | 16 ++++++++++++---- fjs/rtti/parse/proof.f.mjs | 3 +++ fjs/rtti/validate/module.f.mjs | 21 +++++++++++++++++---- fjs/rtti/validate/proof.f.mjs | 6 ++++++ 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/changelog/unreleased/1766.md b/changelog/unreleased/1766.md index c4004fd5d..a4bcd8324 100644 --- a/changelog/unreleased/1766.md +++ b/changelog/unreleased/1766.md @@ -1,5 +1,5 @@ - `rtti`: `validate` and `parse` decide a closed tuple's or struct's member - presence, then bound it by length, before reading any member — so an `or` of - two arities no longer walks shared operands once per arm. Acceptance is - unchanged; a value that is both too long and wrong at a member now reports - the container-level error rather than the member's. + presence and settle any illegal absence, then bound it by length, before + reading any member — so an `or` of two arities no longer walks shared + operands once per arm. Acceptance is unchanged; a container-level or + absent-member error now wins over an earlier member's. diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 04e8f862c..126da6b5f 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -349,13 +349,21 @@ const constContainerParse = if (!fits(value, declared.length)) { return verror('unexpected value') } + // Absence before any read, for the reason `../validate`'s + // copy of this comment gives: reaching an illegal absence + // through the reading walk restores the exponential. + const a = eachEntry( + withPresence, + (_k, [t, present]) => present ? ok(undefined) : absentMember(t), + undefined, + acc => acc, + ) + if (a[0] === 'error') { return a } const r = eachEntry( withPresence, (k, [t, present]) => { - if (!present) { - const a = absentMember(t) - return a[0] === 'error' ? a : ok([]) - } + // Absence is settled above; this walk only records it. + if (!present) { return ok([]) } const p = /** @type {any} */ (parse(t))(getItem(value, k)) return p[0] === 'error' ? p : ok([p[1]]) }, diff --git a/fjs/rtti/parse/proof.f.mjs b/fjs/rtti/parse/proof.f.mjs index 4ef5eb6d4..bb7d85f51 100644 --- a/fjs/rtti/parse/proof.f.mjs +++ b/fjs/rtti/parse/proof.f.mjs @@ -459,6 +459,9 @@ export const proof = { structuralMismatchIsAnsweredFirst: () => { assertErrorPath([])(parse([/** @type {const} */ (42)])([43, 'extra'])) assertErrorPath(['0'])(parse([/** @type {const} */ (42)])([43])) + // and an absent required member answers before the members + // ahead of it are read — see `../validate/proof.f.mjs` + assertErrorPath(['1'])(parse([number, number])(['bad'])) }, // Nor is a key that is no position at all. nonIndexKeyRejected: () => diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index 593fd1d6a..9acf7d557 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -238,13 +238,26 @@ const constContainerValidate = if (!fits(value, declared.length)) { return verror('unexpected value') } + // Absence is answered before **any** member is read. Reaching + // an illegal absence through the reading walk would first + // recurse into the members that come before it, and those are + // the operands the longer arm shares — so an `or` of two + // arities would walk them once per arm at every level, which is + // the exponential all over again on a value the short arm has + // to reject. Measured on a chain of `['.', exp, index]` with a + // leaf no arm accepts: 2.5s at depth 16 without this pass. + const a = eachEntry( + withPresence, + (_k, [v, present]) => present ? ok(undefined) : absentMember(v), + undefined, + acc => acc, + ) + if (a[0] === 'error') { return a } const r = eachEntry( withPresence, (k, [v, present]) => { - if (!present) { - const a = absentMember(v) - return a[0] === 'error' ? a : ok(false) - } + // Absence is settled above; this walk only records it. + if (!present) { return ok(false) } const m = /** @type {any} */ (validate(v))(getItem(value, k)) return m[0] === 'error' ? m : ok(true) }, diff --git a/fjs/rtti/validate/proof.f.mjs b/fjs/rtti/validate/proof.f.mjs index 570a98e38..2c7e4b5e7 100644 --- a/fjs/rtti/validate/proof.f.mjs +++ b/fjs/rtti/validate/proof.f.mjs @@ -874,6 +874,12 @@ export const proof = { for (const read of [v, p]) { assertErrorPath([])(read(t)([43, 'extra'])) } // a member error alone still reports the member for (const read of [v, p]) { assertErrorPath(['0'])(read(t)([43])) } + // an absent required member answers before the members ahead of it + // are read — reaching it through the reading walk would recurse + // into the operands the longer arm shares, which is the exponential + // this order exists to avoid + const two = /** @type {const} */ ([number, number]) + for (const read of [v, p]) { assertErrorPath(['1'])(read(two)(['bad'])) } // and a value that fits is read as before for (const read of [v, p, d]) { assertOk(read(t)([42])) } }, From 4188b188034126f06ddb63d534ecca439b566586 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:13:25 +0000 Subject: [PATCH 288/370] emergent_testing: revert the sharing code, keep everything it taught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner reverted #1759 with every gate green, for the reason the first attempt's record already held: the concurrency was the complexity. The frame budget with its guessed 8 ms constant, the yield-primitive selection, the reporting burst no budget could fix, and the variadic `all` argument ceiling all trace to the traversal fanning out. The todo now carries the second attempt's full record so the next implementer meets none of it again: a pitfall catalog — thirteen problems, each with its cause and the solution that worked, split into what the sequential plan dissolves, what any implementation must keep, and method — and the simplified plan itself. A sequential traversal runs one leaf's whole chain before the next; the page yields one macrotask in its own report handler, the browser's spelling of the CLI's write-a-line; the interpreter is sandbox and catch with no scheduling at all. Speed is explicitly not a goal. Code is byte-identical to main again; the diff is three todo files. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/1759.md | 4 - fjs/effects/browser/module.mjs | 261 ----------- fjs/effects/browser/proof.mjs | 180 -------- fjs/effects/todo/all-argument-limit.md | 17 +- fjs/effects/todo/node-module-layering.md | 19 +- fjs/emergent_testing/browser.mjs | 269 ++++++----- fjs/emergent_testing/browser/proof.mjs | 54 --- fjs/emergent_testing/module.f.mjs | 143 ++---- .../todo/share-browser-console-runner.md | 431 +++++++++++------- fjs/emergent_testing/types.ts | 68 +-- 10 files changed, 465 insertions(+), 981 deletions(-) delete mode 100644 changelog/unreleased/1759.md delete mode 100644 fjs/effects/browser/module.mjs delete mode 100644 fjs/effects/browser/proof.mjs diff --git a/changelog/unreleased/1759.md b/changelog/unreleased/1759.md deleted file mode 100644 index b5ce78b1a..000000000 --- a/changelog/unreleased/1759.md +++ /dev/null @@ -1,4 +0,0 @@ -- **BREAKING CHANGES:** `emergent_testing`: `runModuleMap` answers the run's - outcome — totals and leaf records — not an exit code; `exitCodeOf` derives - that. The browser page shares `fjs t`'s traversal, through an interpreter - that yields on a frame budget diff --git a/fjs/effects/browser/module.mjs b/fjs/effects/browser/module.mjs deleted file mode 100644 index 0e1985b60..000000000 --- a/fjs/effects/browser/module.mjs +++ /dev/null @@ -1,261 +0,0 @@ -/** - * A browser interpreter for the host-independent operations. - * - * It implements exactly three — `sandbox`, which calls a function and reports - * what happened instead of throwing; `catch`, which does the same for a pure - * thunk with no clock; and `all`, which performs its children concurrently. - * None of them names a browser API beyond `performance.now` and `Promise`: - * these are the same operations `effects/node` performs, and this module is the - * "follow the example" reading of its interpreter rather than a second design. - * - * **Three is the whole set, and that is a measurement rather than a starting - * point.** The shared proof traversal (`emergent_testing/module.f.mjs`) - * performs `sandbox`, `catch`, `all`, and whatever its reporter performs. - * `await` belongs to the *registration* path, which external frameworks drive - * and this one does not; a page loads its modules through its own importer - * rather than through an `import` operation; and a browser run measures its own - * wall clock rather than dispatching `now`. So `await`, `import`, `fetch` and - * `now` have no second implementer here — which is the fact - * `emergent_testing/todo/share-browser-console-runner.md` step 4 was waiting to - * learn before moving anything out of `effects/node`. - * - * The module has no Node dependencies: a page imports it directly as an ES - * module. - * - * @module - * - * @import { Effect, Operation, ToAsyncOperationMap } from '../types.ts' - * @import { All, Catch, Sandbox } from '../node/types.ts' - * @import { Result } from '../../types/result/types.ts' - */ - -import { asyncRun } from '../module.mjs' -import { error, ok } from '../../types/result/module.f.mjs' -import { tryCatch } from '../../types/result/module.mjs' - -/** - * How long a run may hold the thread before handing it back, in milliseconds. - * - * It is a *frame* budget rather than a count of proofs, and that difference is - * the whole point. A count cannot know what it costs: twenty-five trivial - * leaves are nothing and twenty-five heavy ones are still a freeze, which is - * why the number this replaces was indefensible. 8 ms is what a 60 Hz frame - * leaves for script, so a page that respects it gets a paint slot at the rate - * it can actually use one, whatever its proofs happen to cost. - */ -const frameBudget = 8 - -/** - * Hands control back to the host's event loop and comes back in a later task. - * - * Not `setTimeout`: it clamps to 4 ms once nested, and this is called between - * leaves, so the clamp would add minutes to a suite of a few thousand. That - * clamp is what pushed an earlier attempt to `MessageChannel` and then into a - * failure under bun, which drains port messages before running a due timer — - * a problem this module does not have, because nothing but a browser runs it. - * - * `scheduler.yield` is the primitive built for exactly this and does not - * clamp; `MessageChannel` is the same idea by hand where it is missing. - * - * @type {() => Promise} - */ -const yieldToHost = () => { - const { scheduler } = /** @type {{ scheduler?: { yield?: () => Promise } }} */ ( - /** @type {unknown} */ (globalThis)) - if (scheduler?.yield !== undefined) { return scheduler.yield() } - return new Promise(resolve => { - const { port1, port2 } = new MessageChannel() - port1.onmessage = () => { - port1.close() - resolve(undefined) - } - port2.postMessage(undefined) - }) -} - -/** - * Calls `f` and answers what happened — its value, or the value it threw — - * together with how long it took. - * - * This is the boundary that keeps a host value out of the pure traversal: the - * `try`/`catch` and the clock live here, and the core receives a - * `SandboxResult` it can read without knowing which host produced it. - * - * A returned promise is awaited and the clock read again after it settles, so - * an asynchronous leaf is timed by what it did rather than by how quickly it - * handed back a promise. Authored FunctionalScript has no promises, so this is - * a guard rather than a path anything is expected to take — the same one - * `effects/node` keeps, spelled the same way, because two runners that - * disagreed about an awaited leaf would not be one runner. - * - * @template T - * @param {() => T} f - * @returns {Promise<{ readonly result: Result, readonly duration: number }>} - */ -const sandbox = async f => { - /** @type {Result} */ - let result - let after - const before = performance.now() - try { - let p = f() - after = performance.now() - if (p instanceof Promise) { - p = await p - after = performance.now() - } - result = ok(p) - } catch (e) { - after = performance.now() - result = error(e) - } - return { result, duration: after - before } -} - -/** - * A browser effect runner for `sandbox`, `catch` and `all`, plus whatever - * `extra` operations the application adds — a page's own reporting, typically. - * - * `all` starts every child before awaiting any, which is a contract rather than - * an implementation detail: a child may wait on something a later sibling - * produces, so an interpreter that awaited one before starting the next would - * hang a graph the node runner completes. It answers in argument order however - * the children interleave, which is what lets the shared traversal report in - * structural order. - * - * `extra` is a **complete** map of the operations it names, not a partial one. - * `asyncRun` dispatches by exact match and panics on a command no handler - * claims, so a type that accepted holes would promise a recovery this runner - * does not perform — an omitted handler rejects the run's promise rather than - * answering `NotImplemented` through the error channel. A host that wants a - * hole to be an ordinary outcome builds its runner on `partialMatch`, the way - * `effects/mock` does. - * - * The runner it answers keeps the effect's own types: a caller reads the - * `Result` it resolves with rather than casting one out of `unknown`. The - * operations are the three below plus `extra`'s, which is what makes an effect - * this runner cannot dispatch a type error rather than a rejected promise. - * - * @template {Operation} O - * @param {ToAsyncOperationMap} extra - * @returns {(effect: Effect) => Promise>} - */ -export const browserRun = extra => { - // `all` interprets its children with the runner being defined, so the loop - // is tied through a self-reference and the map cannot be typed on the way - // in. The cast stops at the `asyncRun` call: what the function answers is - // typed. - /** @type {(effect: any) => Promise} */ - let run - // When this run last gave the thread back, and the yield the leaves over - // budget are all waiting on. Per runner rather than per module, because the - // thread is one thing however many runs share it. - let sliceStart = performance.now() - /** @type {Promise | null} */ - let slice = null - /** - * The yield this leaf must wait for, or `null` when the slice has room. - * - * **Answering `null` rather than an already-resolved promise is the whole - * mechanism**, and it took a measurement to learn it. A leaf runs - * synchronously inside its handler, so `all`'s children start one after - * another as each previous leaf finishes — which is what makes "has this - * slice been spent?" a question with a moving answer. Await anything before - * the leaf, even a resolved promise, and every handler asks the question at - * the same instant, before any leaf has run: all of them see an empty - * budget, none of them yields, and the run is one task again. - * - * Waiters share one yield instead of each taking a task, and re-ask when it - * resolves: the first few resume into the fresh slice and run inline, and - * whichever one finds the budget spent again waits for the next. - * - * @type {() => Promise | null} - */ - const overBudget = () => { - if (performance.now() - sliceStart < frameBudget) { return null } - if (slice === null) { - slice = yieldToHost().then(() => { - slice = null - sliceStart = performance.now() - }) - } - return slice - } - /** - * Charges a handler's work to the budget: it waits when the slice is spent, - * and does not otherwise. - * - * **Every operation is wrapped, not only `sandbox`.** Whatever a runner - * dispatches runs on the host's one thread, so the leaf is not the only - * thing that can hold it: a page whose proofs are trivial and whose - * reporting paints a row spends its time in `report`, and a budget that - * only watched the leaf would let a hundred cheap leaves start inside one - * slice and then drain a hundred paints with no turn given back. The - * operation a host adds is the host's own work, and this is the point that - * knows it. - * - * @type {(handler: (...a: A) => Promise) => (...a: A) => Promise} - */ - const budgeted = handler => async (...payload) => { - let wait = overBudget() - while (wait !== null) { - await wait - wait = overBudget() - } - return handler(...payload) - } - const core = { - all: async (/** @type {readonly any[]} */ ...effects) => - ok(await Promise.all(effects.map(run))), - // **Where the yield cannot be.** `all` starts every child before - // awaiting any — a contract, not an implementation detail — so it - // cannot pause *between* children without hanging a graph whose child - // waits on a later sibling. The budget is charged as each operation is - // dispatched instead, which holds no sibling's answer while it waits. - // - // Without any of this the whole suite runs as one task. Everything - // resolves through microtasks, and a microtask drain never returns to - // the event loop, so a page cannot paint a result, service a timer or - // answer a click from the first proof to the last — measured at ~53 s - // on this repo's own browser suite, long enough for the browser to - // offer to kill the page. - sandbox: async (/** @type {() => unknown} */ f) => ok(await sandbox(f)), - // No clock and no fixture convention — see `Catch` in - // `../node/types.ts` for why this is a second operation beside - // `sandbox` rather than a use of it. It is `tryCatch`, spelled the - // same way `effects/node` spells it: that helper carries no host - // dependency, so there is nothing here for a browser to do - // differently. - catch: async (/** @type {() => unknown} */ f) => ok(tryCatch(f)), - } - // A collision panics rather than being resolved in either direction. The - // runner's answer is typed by these three operations, so an `extra` that - // replaced one would make the type a lie — and silently letting the core - // win instead would discard a handler the caller wrote on purpose. Neither - // is a routine outcome: it is a program claiming an operation this runner - // already has, which is the same class of bug as asking for one it does - // not. - // `extra` is read **once**, and the check and the map are built from that - // one reading. Enumerating is a user-observable operation — a proxy decides - // what it answers, and may answer differently the second time — so a check - // that read it again could approve a map the runner does not build, which - // is the same mistake the page made about proof exports. - // - // The handlers are carried over by descriptor rather than by spread, so a - // map that declares one non-enumerable keeps it. `match` looks a handler up - // with `getOwnPropertyDescriptor`, so this runner accepts exactly what the - // layer's dispatch already accepts — no more, and no less. An inherited - // handler is out of contract there and stays out of contract here. - const handlers = Object.getOwnPropertyDescriptors(extra) - const claimed = Object.keys(handlers).filter(k => Object.hasOwn(core, k)) - if (claimed.length !== 0) { - throw `browserRun: ${claimed.join(', ')} already implemented` - } - const merged = Object.defineProperties({ ...core }, handlers) - run = asyncRun(/** @type {any} */ (Object.fromEntries( - Object.getOwnPropertyNames(merged).map(k => [ - k, - budgeted(/** @type {any} */ (/** @type {any} */ (merged)[k])), - ])))) - return run -} diff --git a/fjs/effects/browser/proof.mjs b/fjs/effects/browser/proof.mjs deleted file mode 100644 index 677709393..000000000 --- a/fjs/effects/browser/proof.mjs +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Proofs for the browser interpreter. - * - * It is a `.mjs` because the interpreter is: these run its real `try`/`catch`, - * its real clock and its real `Promise.all`, which is the whole of what it is. - * - * @import { Result } from '../../types/result/types.ts' - */ - -import { assert, assertEq } from '../../asserts/module.f.mjs' -import { browserRun } from './module.mjs' -import { all, catch_, sandbox } from '../node/module.f.mjs' -import { ok } from '../../types/result/module.f.mjs' -import { do_ } from '../module.f.mjs' - -// No `extra`: these proofs exercise the three operations the interpreter has -// of its own. -const run = browserRun({}) - -/** - * The value a run answered. The runner answers `ok` for every one of these, so - * an `error` here is the proof failing — asserted through the shared helper, - * whose own branches are covered, rather than through a local `if`. - * - * @template T - * @template E - * @param {Result} r - * @returns {T} - */ -const okValue = r => { - assertEq(r[0], 'ok', r) - return /** @type {T} */ (r[1]) -} - -export const proof = { - // A leaf that throws is an answer, not a failure of the run — the same - // bargain `effects/node`'s `sandbox` makes. - sandboxReportsAThrow: async () => { - const { result, duration } = okValue(await run(sandbox(() => { throw 'boom' }))) - assertEq(result[0], 'error') - assertEq(result[1], 'boom') - assert(duration >= 0) - }, - // An asynchronous leaf is timed by what it did, not by how quickly it - // handed back a promise. - sandboxAwaitsAPromise: async () => { - assertEq(okValue(await run(sandbox(() => Promise.resolve(1)))).result[1], 1) - }, - catchAnswersTheThrownValue: async () => { - const r = okValue(await run(catch_(() => { throw 'thrown' }))) - assertEq(r[0], 'error') - assertEq(r[1], 'thrown') - }, - // `all` answers in argument order however its children interleave, which is - // what lets the shared traversal report in structural order. - allAnswersInArgumentOrder: async () => { - const slow = sandbox(() => new Promise(resolve => setTimeout(() => resolve('first'), 10))) - const fast = sandbox(() => 'second') - const r = okValue(await run(all(slow, fast))) - assertEq(okValue(r[0]).result[1], 'first') - assertEq(okValue(r[1]).result[1], 'second') - }, - // **The page must stay alive while a suite runs.** Leaves resolve through - // microtasks, and a microtask drain never returns to the event loop, so - // without a yield the whole suite is one task: no paint, no timer, no - // click, for as long as it takes. This posts a message before the run and - // asks whether it was delivered while the run was still going — under a - // single-task run it cannot be, because nothing else gets a turn until the - // run is over. - theThreadIsGivenBackDuringARun: async () => { - // Well over the frame budget, so the second leaf finds it spent. - const burn = () => { - const end = performance.now() + 25 - while (performance.now() < end) { /* hold the thread */ } - } - let deliveredDuringRun = false - let finished = false - const { port1, port2 } = new MessageChannel() - port1.onmessage = () => { deliveredDuringRun = !finished } - port2.postMessage(undefined) - // Its own runner, so the slice starts here: the first leaf runs inline - // and the second finds the budget spent, which is the moment the thread - // has to come back. - const r = okValue(await browserRun({})(all(sandbox(burn), sandbox(burn), sandbox(burn)))) - finished = true - port1.close() - port2.close() - assertEq(r.length, 3) - assert(deliveredDuringRun) - }, - // **Every operation is charged, not only the leaf.** A page whose proofs are - // trivial and whose reporting paints a row spends its time in the operation - // it added, so a budget that watched `sandbox` alone would let a hundred - // cheap leaves start in one slice and then drain a hundred paints. - // - // Asserted by ordering rather than by observing a turn: a macrotask cannot - // run until every pending microtask has, so racing the dispatch against a - // long chain of microtasks says which kind of boundary it waited for, and - // says it the same way however busy the process is. A proof that watched - // for *a* turn instead would pass whenever anything else in the suite - // happened to yield nearby — green with the defect present, which is worse - // than no proof. - everyOperationIsChargedToTheBudget: async () => { - const run = browserRun(/** @type {any} */ ({ mark: async () => ok('marked') })) - // Spend the slice before dispatching, so the budget is owed. - const end = performance.now() + 25 - while (performance.now() < end) { /* hold the thread */ } - const dispatched = run(/** @type {any} */ (do_('mark'))()).then(() => 'operation') - const microtasks = (async () => { - for (let i = 0; i < 200; i += 1) { await null } - return 'microtasks' - })() - assertEq(await Promise.race([dispatched, microtasks]), 'microtasks') - assertEq(okValue(await dispatched.then(() => run(/** @type {any} */ (do_('mark'))()))), 'marked') - }, - // Enumerating `extra` runs user code too: a proxy may answer one set of - // keys and then another. Reading it once means the map the runner builds is - // the map the collision check approved — here the second reading's - // `sandbox` is never seen at all, so the core handler stands rather than - // being replaced behind the check's back. - twoFacedExtraCannotReplaceACoreHandler: async () => { - let reads = 0 - const extra = new Proxy({}, { - ownKeys: () => { - reads += 1 - return reads === 1 ? [] : ['sandbox'] - }, - getOwnPropertyDescriptor: () => ({ - value: async () => ok('replaced'), - configurable: true, - enumerable: true, - }), - }) - const r = okValue(await browserRun(/** @type {any} */ (extra))(sandbox(() => 42))) - assertEq(reads, 1) - assertEq(okValue(r.result), 42) - }, - // `match` looks a handler up by own-property descriptor, so an `extra` that - // declares one non-enumerable is still a valid operation map. Carrying the - // handlers over by spread would have dropped it and turned a dispatch this - // layer supports into a rejected promise. - nonEnumerableHandlerIsDispatched: async () => { - const extra = Object.defineProperty({}, 'quiet', { - value: async () => ok('answered'), - enumerable: false, - }) - const r = await browserRun(/** @type {any} */ (extra))( - /** @type {any} */ (do_('quiet'))()) - assertEq(okValue(r), 'answered') - }, - // The mirror of the panic below: a program that claims an operation this - // runner already implements is the same class of bug as one that asks for - // an operation it lacks. Resolving it either way would be silent — the - // answer's type would be a lie, or the caller's handler would be dropped. - collidingOperationIsRejected: async () => { - let message - // Side effect: `try`/`catch` is not allowed in FunctionalScript. - try { - browserRun(/** @type {any} */ ({ sandbox: async () => ok('replaced') })) - } catch (e) { - message = e - } - assertEq(message, 'browserRun: sandbox already implemented') - }, - // A command no handler claims is a panic, not a `NotImplemented` answer: - // this runner dispatches by exact match, which is why `browserRun` asks for - // a complete map of the operations it is given. A host that wants a hole to - // be an ordinary outcome builds on `partialMatch` instead. - missingOperationRejects: async () => { - let thrown = false - // Side effect: `try`/`catch` is not allowed in FunctionalScript, which - // is why this proof is not one. - try { - await run(/** @type {any} */ (do_('missing'))()) - } catch { - thrown = true - } - assert(thrown) - }, -} diff --git a/fjs/effects/todo/all-argument-limit.md b/fjs/effects/todo/all-argument-limit.md index 83ff26074..09457c2c7 100644 --- a/fjs/effects/todo/all-argument-limit.md +++ b/fjs/effects/todo/all-argument-limit.md @@ -14,7 +14,6 @@ ceiling. Every site in the repository today: | `emergent_testing/module.f.mjs` `walkEntries` | one module's sibling leaves | | `emergent_testing/module.f.mjs` `runModuleMap` | the modules of a run | | `emergent_testing/module.f.mjs` `registerModule` ×2, `registerModuleMap` | the same two, for the framework-registration path | -| `emergent_testing/browser.mjs` `runBrowserProofs` | the page's module list | | `dev/module.f.mjs` ×2 | files to load, and their imports | They fail independently: a suite of a hundred thousand *modules* breaks the outer spread @@ -39,12 +38,16 @@ than a few tens of thousands of leaves. Nothing in this repository is close — suite is 3,461 leaves across 138 modules — so this is a real ceiling rather than a live problem, and it is recorded rather than fixed for that reason. -The browser runner used to avoid it accidentally: it fanned out in batches of 25, so it -never spread more than 25 arguments. That batching is gone -([share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md)), -deliberately and for good reasons, and with it went a protection nobody had asked for or -noticed. Both runners now share the ceiling, which is at least honest: one traversal, one -limit, one place to fix it. +The browser runner avoids it accidentally: it fans out in batches of 25 through +`Promise.all`, so it never spreads more than 25 arguments — a protection nobody asked for +or noticed, one of three unnamed jobs that constant turned out to do (see the pitfall +catalog in +[share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md)). +The reverted functionalscript#1759 routed the page through the shared traversal and so +briefly gave both runners the same ceiling; the sequential plan that replaced it removes +the traversal's fan-outs entirely, which retires the `walkEntries` and `runModuleMap` rows +above. What remains then is the registration path and `dev` — still the operation's +problem, at fewer sites. ### Proposal diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index 09e6284e8..925c6c354 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -78,13 +78,18 @@ Judgement calls worth deciding explicitly rather than by accident: interpreter actually implements — so both recorded the disagreement and left it to step 5. - Step 5's answer is `sandbox`, `catch` and `all`, and nothing else. - `fjs/effects/browser/module.mjs` implements those three because the shared - proof traversal performs those three; a page loads its modules through its - own importer rather than an `import` operation, measures its own wall clock - rather than dispatching `now`, and performs no `fetch` at all. So none of the - three gained a second implementer, and DESIGN.md §4 keeps them here until one - does. + Step 5's answer was `sandbox`, `catch` and `all`, and nothing else: the + browser interpreter built (and later reverted, with its record) in + functionalscript#1759 implemented those three because the shared proof + traversal performed those three. A page loads its modules through its own + importer rather than an `import` operation, measures its own wall clock + rather than dispatching `now`, and performs no `fetch` at all. So none of + the three gained a second implementer, and DESIGN.md §4 keeps them here + until one does. The sequential plan that replaced that attempt (see + share-browser-console-runner) shrinks the measured set once more: a + sequential traversal performs no `all`, so the operations with a second + implementer become `sandbox` and `catch` alone, and `all` stays here with + the registration path. Worth recording, because the earlier expectation written here was wrong about two of them: "a browser proof run needs a clock and dynamic import" is true of diff --git a/fjs/emergent_testing/browser.mjs b/fjs/emergent_testing/browser.mjs index fabdc71a5..fe5d781b7 100644 --- a/fjs/emergent_testing/browser.mjs +++ b/fjs/emergent_testing/browser.mjs @@ -13,17 +13,12 @@ * iframe therefore renders into that frame, and a proof can drive the module * with a stand-in root. * - * @import { BrowserTestReport, Reporter, TestResult, _BrowserImporter, _BrowserReport, _BrowserTestResult, _TestAndPath } from './types.ts' - * @import { Effect, Func, ToAsyncOperationMap } from '../effects/types.ts' - * @import { All, Catch, IoChannel, Sandbox, SandboxResult } from '../effects/node/types.ts' + * @import { BrowserTestReport, TestResult, _BrowserImporter, _BrowserTestResult, _TestAndPath } from './types.ts' * @import { Result } from '../types/result/types.ts' */ -import { addResult, collectTests, defaultTest, runEntries, zeroTotals } from './module.f.mjs' -import { browserRun } from '../effects/browser/module.mjs' -import { allOk } from '../effects/node/module.f.mjs' -import { do_, mapStep, pureOk } from '../effects/module.f.mjs' -import { ok } from '../types/result/module.f.mjs' +import { addResult, collectTests, testResult, zeroTotals } from './module.f.mjs' +import { error as errorResult, invert, ok } from '../types/result/module.f.mjs' /** @type {(value: unknown) => string} */ const text = value => { @@ -82,51 +77,110 @@ const moduleFailure = (source, duration, message, stack) => ({ module: source, path: '', name: source, status: 'failed', duration, message, stack, }) -/** - * The `report` operation's constructor: hand one leaf record to the page. - * - * @type {Func<_BrowserReport>} - */ -const report = do_('report') - -/** - * The page's leaf record, built from what the shared traversal decided. - * - * `t` arrives already decided — identity, status and duration all come from - * `testResult` inside the traversal — so the only thing left here is the part - * `TestResult` deliberately leaves to each host: how to *describe* a failure. - * A passing leaf needs no description; a failing one is described from the - * value, except for the one case a value cannot describe, where a proof marked - * `throw` returned instead of throwing and the failure is the absence of a - * throw rather than anything thrown. - * - * @type {(t: TestResult, r: SandboxResult, throws: boolean) => _BrowserTestResult} - */ -const browserResult = (t, r, throws) => { - if (t.status === 'passed') { return t } - if (throws) { - return { ...t, message: 'Expected the proof to throw', stack: '' } +/** @type {(module: string, path: readonly (string | null)[], throws: boolean, fn: () => unknown, result: (result: _BrowserTestResult) => void) => Promise} */ +const runOne = (module, path, throws, fn, result) => { + const start = performance.now() + // The throw expectation is applied with the same `invert` the console + // runner's `defaultTest` uses, and the status is then read off the result by + // the same `testResult`. Both runners therefore answer "did this leaf pass" + // in one place — the rule that used to be spelled out at four sites here and + // once again over there. + /** @type {(o: Result, duration: number) => TestResult} */ + const leaf = (o, duration) => + testResult(module, path, { result: throws ? invert(o) : o, duration }) + /** @type {(value: unknown) => Promise | readonly _BrowserTestResult[]} */ + const passed = value => { + const duration = performance.now() - start + if (throws) { + const failure = { ...leaf(ok(value), duration), + message: 'Expected the proof to throw', stack: '' } + result(failure) + return [failure] + } + // Reading the returned tree runs user code: an enumerable getter + // or a proxy trap can throw. That is a failure of the test that + // produced the value, never of the run — a rejected run leaves the + // page in `running` with no report and no completion event. + /** @type {readonly _TestAndPath[]} */ + let children + try { + children = collectTests([...path, null], false, value) + } catch (error) { + return failed(error) + } + return Promise.all(children.map(([childPath, child]) => + runOne(module, childPath, child.throws, child.fn, result) + )).then(results => { + const success = leaf(ok(value), duration) + result(success) + return [success, ...results.flat()] + }) + } + /** @type {(error: unknown) => readonly _BrowserTestResult[]} */ + const failed = error => { + const duration = performance.now() - start + if (throws) { + const success = leaf(errorResult(error), duration) + result(success) + return [success] + } + const [message, stack] = errorDetails(error) + const failure = { ...leaf(errorResult(error), duration), message, stack } + result(failure) + return [failure] + } + // `instanceof Promise`, then `await` — the whole of `fjs t`'s promise + // handling, spelled the same way here. + // + // It is deliberately not more than that. A promise can replace its own + // `then`, present a `constructor` that is not the intrinsic `Promise`, or + // carry a `Symbol.species` that fails, and each of those defeats `await` in + // a different way. Defending against them takes about 150 lines, none of + // which authored FunctionalScript can reach: it has no `Promise`, no + // `class`, no `Proxy` and no `Symbol`. `todo/imports-promises-realms.md` + // records each case, what the deleted machinery did about it, and what a + // runner does without it — to be implemented when an input that needs it + // actually exists. + // + // The value is wrapped in a tuple first so that resolving it cannot + // assimilate a proof tree carrying a `then` key: such a tree is a sub-tree + // with a test called `then` in it, in both runners. + // + // What makes this enough is the `await` above, not an assumption about the + // values that reach it. FunctionalScript as specified has no promises, and + // the browser suite selects `.f.mjs` — but that selection is by filename + // with no content check (`website/browser-prepare.mjs`), so a module that + // does not conform is still loaded and can return one. The handling here is + // correct either way. See `todo/imports-promises-realms.md` for the + // machinery this replaces and the measurements behind removing it. + /** @type {(value: unknown) => Promise | readonly _BrowserTestResult[]} */ + const settled = async value => { + // Even the brand check runs user code: `instanceof` consults + // `getPrototypeOf`, which a proxy can trap and a revoked one always + // throws from. `fjs t` performs this check inside `sandbox`'s + // `try`/`catch`, so it reports such a value as its test's failure; this + // handler has no enclosing `try`, so without one here the whole run + // rejects and the page never leaves `running`. + let isPromise = false + try { + isPromise = value instanceof Promise + } catch (error) { + return failed(error) + } + if (!isPromise) { return passed(value) } + /** @type {readonly [unknown]} */ + let resolved + // Only the `await` is guarded. A throw from `passed` is the traversal's + // own and has its own handling; catching it here would report a broken + // proof tree as a rejected promise. + try { + resolved = [await value] + } catch (error) { + return failed(error) + } + return passed(resolved[0]) } - const [message, stack] = errorDetails(r.result[1]) - return { ...t, message, stack } -} - -/** - * The page's half of the shared runner. - * - * `test` is `defaultTest` — the same sandboxing and the same `invert` `fjs t` - * uses — so "did this leaf pass" is not decided here at all. `result` builds - * the page's record and hands it to the `report` operation, whose value the - * traversal keeps in structural order. `summary` has nothing to do: the page - * renders its report from the outcome it is handed, rather than from an event - * telling it the run ended. - * - * @type {Reporter} - */ -const browserReporter = { - test: defaultTest, - result: (t, r, throws) => report(browserResult(t, r, throws)), - summary: () => pureOk(undefined), + return Promise.resolve().then(() => [fn()]).then(([value]) => settled(value), failed) } /** @@ -175,93 +229,36 @@ export const runBrowserProofs = (modules, result = () => undefined) => { // The result stays in the report the run resolves with. } } - /** - * Everything that runs user code, held until the caller has this run's - * promise. - * - * A leaf executes synchronously inside its handler — that is what staggers - * the traversal's siblings — so without this the first slice of proofs - * would run while `runBrowserProofs` was still building what it returns, - * before `startBrowserTests` had published the promise as - * `fjsBrowserTestReport`. A proof that reads the run it belongs to would - * see the previous run's, or nothing. Enumerating an export is user code - * too, so it waits here as well. - * - * @type {() => Promise>} - */ - const started = () => { - // Reading a module's exported tree runs user code, and the shared traversal - // deliberately does not guard that one: there is no leaf to attribute it - // to, so `fjs t` panics and the page does this instead. A module that - // cannot be enumerated is one failed module, never a run that ends without - // a report. See `todo/hostile-proof-values.md`. - // - // The export is read **once**, here, and the leaves go on to `runEntries`: - // enumerating is not idempotent, so a preliminary read that only checked - // whether the tree can be enumerated would run every getter in it a second - // time — and a getter that succeeds once and throws next would escape as a - // synchronous throw, leaving the page in `running` with no report at all. - /** @type {readonly (readonly ['ok', string, readonly _TestAndPath[]] | readonly ['failed', _BrowserTestResult])[]} */ - const prepared = modules.map(([module, proof]) => { - try { - return /** @type {const} */ (['ok', module, collectTests([], false, proof)]) - } catch (error) { - const [message, stack] = errorDetails(error) - return /** @type {const} */ (['failed', moduleFailure(module, 0, message, stack)]) - } - }) - // The page's modules are a *list*, and nothing stops it naming the same - // module twice: two entries with one label are two runs, in the order they - // were passed, so they are run as a list rather than folded into a map - // keyed by name. - /** @type {(e: (typeof prepared)[number]) => Effect} */ - const runOne = e => e[0] === 'ok' - ? mapStep(runEntries(browserReporter)(e[1], e[2]), o => o.results) - // A module failure has no leaf to be reported by, so it is handed to - // the same `report` operation directly: the page renders it as it - // lands, in the position the module was passed in, exactly like a leaf. - : mapStep(report(e[1]), r => /** @type {readonly _BrowserTestResult[]} */ ([r])) - const all = mapStep(allOk(...prepared.map(runOne)), lists => lists.flat()) - /** @type {ToAsyncOperationMap<_BrowserReport>} */ - const page = { - // The page's end of the `report` operation: render as it lands, and - // answer the record back so the traversal can keep it in order. - report: async r => { - announce(r) - return ok(r) - }, - } - return browserRun(page)(all) - } - /** - * The run failed as a *runner*, not as a proof. Reporting it as the run's - * own failure keeps the page out of `running` forever, which is the one - * outcome a page must never reach. - * - * @type {(e: unknown) => BrowserTestReport} - */ - const infrastructureError = e => { - const [message, stack] = errorDetails(e) - const failure = moduleFailure('', performance.now() - start, message, stack) + /** @type {(module: string, error: unknown) => () => Promise} */ + const unreadable = (module, error) => () => { + const [message, stack] = errorDetails(error) + const failure = moduleFailure(module, 0, message, stack) announce(failure) - return reportOf(performance.now() - start, [failure], 'infrastructure-error') + return Promise.resolve([failure]) } - // Both ways a run can fail as a runner end here. The error channel carries - // what an operation reported; the rejection carries what the interpreter - // could not answer at all — `asyncRun` panics on a command no handler - // claims, so a traversal or reporter that grew an operation this page does - // not implement arrives as a rejected promise rather than an `error`. - // Neither may escape: an unhandled rejection is a page stuck in `running` - // with no report and no completion event. - return Promise.resolve().then(started).then(outcome => { - if (outcome[0] === 'error') { - return infrastructureError(outcome[1]) + const tests = modules.flatMap(([module, proof]) => { + // Reading an exported tree runs user code just as reading a returned + // one does. A module that cannot be enumerated is one failed module, + // never a run that ends without a report. + try { + return collectTests([], false, proof).map(([path, entry]) => + () => runOne(module, path, entry.throws, entry.fn, announce) + ) + } catch (error) { + return [unreadable(module, error)] } - // `allOk` answers in argument order, so the records are already in the - // order the page passed its modules in, with each module's leaves in - // structural order inside it. - return reportOf(performance.now() - start, outcome[1]) - }, infrastructureError) + }) + const batchSize = 25 + /** @type {(index: number, results: readonly _BrowserTestResult[]) => Promise} */ + const runBatch = (index, results) => { + const batch = tests.slice(index, index + batchSize) + if (batch.length === 0) { return Promise.resolve(results) } + return Promise.all(batch.map(test => test())).then(next => + new Promise(resolve => setTimeout(resolve, 0, [...results, ...next.flat()])) + ).then(next => runBatch(index + batchSize, next)) + } + const completed = runBatch(0, []) + return completed.then(results => reportOf(performance.now() - start, results)) } /** @type {(root: Element) => (Window & { fjsBrowserTestReport?: Promise }) | null} */ diff --git a/fjs/emergent_testing/browser/proof.mjs b/fjs/emergent_testing/browser/proof.mjs index b749150b5..6b542ec61 100644 --- a/fjs/emergent_testing/browser/proof.mjs +++ b/fjs/emergent_testing/browser/proof.mjs @@ -354,60 +354,6 @@ export const proof = { assertStructurallySame([...p.states], ['running', 'failed']) assertEq(p.view.events.length, 1) }, - // A parent precedes the children its return value produced, however deep - // the chain goes — the records are joined as a rope and walked out once, - // so nesting must not reorder them the way a per-level rebuild could. - deepChainKeepsStructuralOrder: async () => { - const report = await run({ a: () => ({ b: () => ({ c: () => ({ d: () => undefined }) }) }) }) - assertEq(report.totals.tests, 4) - assertStructurallySame( - report.results.map(r => r.path), - ['.a', '.a().b', '.a().b().c', '.a().b().c().d']) - }, - // A leaf runs synchronously inside its handler, so a run that started while - // its own promise was still being built would execute proofs before the - // page had published it. A proof that asks for the run it belongs to gets - // this run's promise, never the last one's. - aProofSeesItsOwnRunPublished: async () => { - const p = page() - /** @type {unknown} */ - let seen = 'never ran' - const report = await startBrowserTests(p.root, - [['m', { t: () => { seen = p.view.fjsBrowserTestReport } }]]) - assertEq(report.totals.passed, 1) - assertEq(seen, p.view.fjsBrowserTestReport) - assert(seen instanceof Promise) - }, - exportedTreeIsReadOnce: async () => { - // The export is enumerated exactly once. A getter that succeeds on the - // first read and throws on the next is not a module failure here — but - // it is proof that nothing reads the tree twice, and a second read - // would escape as a synchronous throw, leaving the page in `running`. - let reads = 0 - const proof = { - get t() { - reads += 1 - if (reads > 1) { throw new Error('second read') } - return () => undefined - }, - } - const report = await runBrowserProofs([['m', proof]]) - assertEq(reads, 1) - assertEq(report.status, 'passed') - assertEq(report.totals.passed, 1) - }, - // The page's modules are a list, not a map: nothing stops it naming the - // same module twice, and both entries are their own run. - repeatedModuleLabelsBothRun: async () => { - const report = await runBrowserProofs([ - ['m', { first: () => undefined }], - ['m', { second: () => undefined }], - ]) - assertEq(report.totals.tests, 2) - assertStructurallySame( - report.results.map(r => r.path), - ['.first', '.second']) - }, returnedTreeThrows: async () => { // Reading the returned tree runs user code. When it throws, the test // that produced the value fails and the page still reaches a terminal diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index 6ef49cd6e..58b822cd3 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -2,8 +2,8 @@ * Test-framework helpers for running and reporting FunctionalScript tests. * * Two parallel execution paths: - * - `runModule` / `Reporter` — self-hosted Effects runner used by `fjs t`; - * sandboxes each leaf call individually and accumulates a `RunOutcome`. + * - `runModule` / `Reporter` — self-hosted Effects runner used by `fjs t`; + * sandboxes each leaf call individually and accumulates `RunTotals`. * - `registerModule` / `TestContext` — registers tests with an external * framework (Node `--test`, Bun, Deno) at import time; the framework owns * scheduling and pass/fail counting. @@ -14,7 +14,7 @@ * @import { Result } from '../types/result/types.ts' * @import { Effect, NotImplemented } from '../effects/types.ts' * @import { LoadModuleOperations, ModuleMap } from '../dev/types.ts' - * @import { TestFn, TestEntry, TestSet, Path, Reporter, RunOutcome, RunTotals, TestResult, _RunAcc, _TestAndPath } from './types.ts' + * @import { TestFn, TestEntry, TestSet, Path, Reporter, RunTotals, TestResult, _TestAndPath } from './types.ts' * @import { All, Await, Catch, Env, IoChannel, NodeProgram, NodeProgramOptions, Program, Sandbox, SandboxResult, Test, TestContext, Write, WriteConsoles } from '../effects/node/types.ts' */ @@ -25,7 +25,6 @@ import { } from '../effects/module.f.mjs' import { loadModuleMap } from '../dev/module.f.mjs' import { invert } from '../types/result/module.f.mjs' -import { flat, toArray } from '../types/list/module.f.mjs' import { definedEntries } from '../types/object/module.f.mjs' /** @@ -184,47 +183,12 @@ const mergeTotals = (a, b) => ({ passed: a.passed + b.passed, failed: a.failed + b.failed, duration: a.duration + b.duration }) /** - * Joins what a walk accumulated, keeping the leaf records in the order it - * produced them — which is what makes a host's report ordered by structure - * rather than by which leaf settled first. - * - * Nothing is copied here. Both places a walk joins — siblings fanned out, and a - * parent in front of the children its return value produced — hand the records - * on as `List` nodes, so a wide module and a deep one both cost one node per - * join instead of a copy of everything joined so far. See {@link _RunAcc}. - * - * @type {(a: readonly _RunAcc[]) => _RunAcc} - */ -const joinAcc = a => ({ - totals: a.reduce((t, o) => mergeTotals(t, o.totals), zeroTotals), - results: flat(a.map(o => o.results)), -}) - -/** - * The array of leaf records a host is answered with, walked out of the rope - * once. Done where a run ends rather than inside the walk, so no level pays - * for the levels below it. - * - * @type {(a: _RunAcc) => RunOutcome} - */ -const outcomeOf = a => ({ totals: a.totals, results: toArray(a.results).map(b => b.value) }) - -/** - * Runs already-collected leaves under the module name `k`. - * - * This is the seam for a host that enumerates its own modules: the browser - * page reads each export inside its own `try`, because a module that will not - * enumerate is one failed module there rather than a dead run, and because its - * modules arrive as a *list* that may name the same module twice — neither of - * which a `ModuleMap` keyed by module name can express. - * * @template {Operation} O - * @template R - * @param {Reporter} reporter - * @returns {(k: string, entries: readonly _TestAndPath[]) => Effect, IoChannel>} + * @param {Reporter} reporter + * @returns {(k: string, v: unknown) => (ts: RunTotals) => Effect} */ -export const runEntries = ({ result, test }) => (k, entries) => { - /** @type {(entry: _TestAndPath) => Effect, IoChannel>} */ +const runModule = ({ result, test }) => (k, v) => ts => { + /** @type {(entry: _TestAndPath) => Effect} */ const one = ([testPath, set]) => { // The leaf's shared record is built here, next to the sandbox result it // is read from, so the leaf-landed event carries the value already @@ -269,94 +233,56 @@ export const runEntries = ({ result, test }) => (k, entries) => { ([t, sr]) => result(t, sr, set.throws)) return step( reported, - ([r, [t, , children]]) => { - /** @type {_RunAcc} */ - const self = { totals: addResult(zeroTotals, t), results: [{ value: r }] } + ([, [t, sr, children]]) => { + const total = addResult(zeroTotals, t) if (children.length === 0) { - return pureOk(self) + return pureOk(total) } - // The leaf's own record goes first, so a parent precedes the - // children its return value produced. return mapStep( walkEntries(children), - sub => joinAcc([self, sub])) + sub => mergeTotals(total, sub)) }) } - /** @type {(entries: readonly _TestAndPath[]) => Effect, IoChannel>} */ + /** @type {(entries: readonly _TestAndPath[]) => Effect} */ const walkEntries = entries => - // `allOk` answers in argument order however the effects interleave, so - // siblings stay in declaration order even though they run concurrently. - mapStep(allOk(...entries.map(one)), joinAcc) - return mapStep(walkEntries(entries), outcomeOf) + mapStep(allOk(...entries.map(one)), states => states.reduce(mergeTotals, zeroTotals)) + // The *module's* own export is read unguarded, and that asymmetry is + // deliberate rather than an oversight: there is no leaf to attribute it to, + // so an unreadable `proof` export is whatever loaded the module's problem. + // `fjs t` panics on one; the browser page catches it and reports one failed + // module. See `todo/hostile-proof-values.md`. + return mapStep(walkEntries(collectTests([], false, v)), delta => mergeTotals(ts, delta)) } -/** - * Runs everything reachable from one module's `proof` export. - * - * The export is enumerated here, and **unguarded** — that asymmetry is - * deliberate rather than an oversight: there is no leaf to attribute the - * failure to, so an unreadable `proof` export is whatever loaded the module's - * problem. `fjs t` panics on one; the browser page catches it and reports one - * failed module. See `todo/hostile-proof-values.md`. - * - * A caller that has already collected the leaves — because it enumerates under - * its own guard, or because its modules are a list that may name the same - * module twice — calls {@link runEntries} directly instead. Enumerating is not - * idempotent: a getter in the export runs again on every read. - * - * @template {Operation} O - * @template R - * @param {Reporter} reporter - * @returns {(k: string, v: unknown) => Effect, IoChannel>} - */ -const runModule = reporter => (k, v) => - runEntries(reporter)(k, collectTests([], false, v)) - /** @type {(moduleMap: ModuleMap) => readonly (readonly [string, unknown])[]} */ const proofEntries = moduleMap => definedEntries(moduleMap) .flatMap(([k, v]) => v.proof !== undefined ? [/** @type {const} */ ([k, v.proof])] : []) /** - * Runs all test modules in `moduleMap` whose names pass `isTest`, reporting - * each leaf through `reporter` and its totals through `reporter.summary`. - * - * The answer is the run's {@link RunOutcome}: the folded totals, and every - * leaf record the reporter answered with, in structural order. A caller that - * wants the run's exit code asks {@link exitCodeOf} for it. + * Runs all test modules in `moduleMap` whose names pass `isTest`, accumulates + * pass/fail/time via `reporter`, and returns an exit code (0 = all passed, + * 1 = at least one failure). * * @template {Operation} O - * @template R - * @param {Reporter} reporter - * @returns {(moduleMap: ModuleMap) => Effect, IoChannel>} + * @param {Reporter} reporter + * @returns {(moduleMap: ModuleMap) => Effect} */ export const runModuleMap = reporter => moduleMap => { const { summary } = reporter const modules = proofEntries(moduleMap) - // Each module has already walked out its own records, so joining the - // modules copies each record once and nothing more. const total = mapStep( - allOk(...modules.map(([k, v]) => runModule(reporter)(k, v))), - m => ({ - totals: m.reduce((t, o) => mergeTotals(t, o.totals), zeroTotals), - results: m.flatMap(o => o.results), - })) - // The outcome is still needed after the summary has been printed, so it is - // carried forward in a history rather than closed over by a nested + allOk(...modules.map(([k, v]) => runModule(reporter)(k, v)(zeroTotals))), + m => m.reduce(mergeTotals, zeroTotals)) + // The totals are still needed after the summary has been printed, so they + // are carried forward in a history rather than closed over by a nested // continuation. const reported = historyStep( history(total), - o => summary(o.totals)) - return mapStep(reported, ([, o]) => o) + summary) + return mapStep(reported, ([, ts]) => ts.failed !== 0 ? 1 : 0) } -/** - * The exit code a run's outcome means: `1` when any leaf failed. - * - * @type {(o: RunOutcome) => number} - */ -export const exitCodeOf = o => o.totals.failed !== 0 ? 1 : 0 - /** * Ends a run with the exit code it computed, reporting a channel failure on * `stderr` as exit `1`. @@ -392,14 +318,11 @@ const exitCodeStep = e => * reason on `stderr` instead of unwinding as a panic. * * @template {Operation} O - * @template R - * @param {Reporter} reporter + * @param {Reporter} reporter * @returns {Program} */ export const testAll = reporter => options => - exitCodeStep(mapStep( - step(loadModuleMap(options.env), runModuleMap(reporter)), - exitCodeOf)) + exitCodeStep(step(loadModuleMap(options.env), runModuleMap(reporter))) /** * Registers all modules in `moduleMap` that export a `proof` property with @@ -520,7 +443,7 @@ const fmtResultLine = ({ name, duration }, color, label) => * annotations instead of colored lines. Exported as a factory so the * GitHub format path can be exercised directly from tests. * - * @type {(options: NodeProgramOptions) => Reporter} + * @type {(options: NodeProgramOptions) => Reporter} */ export const defaultReporter = options => { const write = csiWrite(options) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index fafaad024..b86b6d773 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -23,7 +23,9 @@ entry much larger than it needs to be. ### How to do this — read before designing -An attempt at this issue was written, reviewed, approved and then reverted. It +A first attempt at this issue was written, reviewed, approved and then +reverted (a second, #1759, followed and is recorded in its own section below). +It worked: one shared `runModuleMap`, a `Reporter` per host, an `effects/common` layer, a browser interpreter, 100% coverage, green CI, a real Chromium run of 3435 proofs. It was reverted anyway, because *how* it got there is a cost this @@ -47,7 +49,7 @@ supplies, rather than a special case. Differences between the parts are fine and expected: a DOM row and a terminal line are two implementations of the same named part, and the skeleton above them -cannot tell which it has. *Undocumented* differences are not. The attempt shared +cannot tell which it has. *Undocumented* differences are not. The first attempt shared the modules and then let the browser keep its own test-name format, its own scheduling policy and its own clock — none of which its host forced, and none of which belonged in a part. That is the failure mode: it *looks* like success — @@ -55,8 +57,11 @@ one module, one name — while two behaviours hide behind it, and two implementations behind two names would have been more honest, because nothing about the shared name signals the difference. -**`fjs t` is sequential, and that is a decision to copy, not a gap to fill.** -The attempt gave the browser a batch size — proofs launched in groups with a +**`fjs t` was sequential when that attempt forked from it, and that was a +decision to copy, not a gap to fill.** (Today's `fjs t` fans out through +`all`; the sequential plan below returns it to this paragraph's state, which +is the state everything here argues for.) The first attempt gave the browser a batch +size — proofs launched in groups with a yield between groups. Nobody had asked for it, no measurement motivated the constant, and it was premature optimization in the strict sense: it made the runner different from the example in order to solve a problem no one had @@ -71,13 +76,14 @@ running a due timer. Six rounds of review, every one of them downstream of a constant that was finally deleted. **And "no batch size" is not "no yielding" — this file said so badly enough to -mislead a later reader, which was me.** Copying `fjs t` exactly *does* freeze a -page: without a yield the whole suite runs as one task, measured at 54.7 s on -this repo's own browser suite, and the line above about the batching having no -paint boundary is about a bug in that attempt rather than a finding that the -yield did nothing. What the browser needs is a turn, on a budget it can defend -— a frame — and what it never needed was a number of proofs. Step 5's task list -records where that landed and what it measured. +mislead a later reader, which was me.** Copying today's concurrent `fjs t` +exactly *does* freeze a page: without a yield the whole suite runs as one +task, measured at 54.7 s on this repo's own browser suite, and the line above +about the batching having no paint boundary is about a bug in that attempt +rather than a finding that the yield did nothing. What the browser needs is a +turn per unit of work, and what it never needed was a number of proofs. The +second attempt's record below carries where that ended: with a *sequential* +run, the turn is one macrotask per report, in the page's own handler. **A problem the browser reveals is not a browser problem.** Two came up, and both are properly issues rather than fixes inside a port: @@ -99,7 +105,7 @@ browser could have is an issue, not something to introduce inside a port. A behaviour the port cannot preserve is a finding to record before it merges, not a silent divergence to explain in review. -**Keep the change reviewable.** The attempt was 2646 insertions and 1408 +**Keep the change reviewable.** The first attempt was 2646 insertions and 1408 deletions across 35 files in one PR — a move, a rewrite, a new effects layer, a new host interpreter and a scheduling invention at once, which is why the scheduling argument could not be separated from the sharing argument. Sequence @@ -107,9 +113,160 @@ it: the shared semantics first, with `fjs t` unchanged in behaviour and the browser file only calling into it; the layout moves after; anything genuinely new last, on its own. +### The second attempt (#1759), and the plan it simplified to + +A second attempt was also written, reviewed — twenty-one review threads, two +independent approvals — and reverted with every gate green: `tsc`, 3,547 +proofs, 100% coverage, a real Chromium run. It shared the traversal exactly as +the steps below asked: one `runModuleMap`, a `Reporter` answering each +host's own leaf record, a browser interpreter for `sandbox`, `catch` and +`all`. The owner reverted it for a reason the first attempt's record already +contains but did not say loudly enough: **the concurrency was the complexity.** +Every hard problem the review fought traces to the traversal fanning out with +`all`, and the machinery each fix added — a frame budget, a guessed 8 ms +constant, `scheduler.yield`/`MessageChannel` selection — is infrastructure a +test runner shouldn't need. The requirement, stated by the owner: a simple, +sequential run, no optimization, a clear message after each test, exactly as +the CLI works. Speed is explicitly not a goal. + +#### The plan: sequential + +Run one leaf's **whole chain** — test, report, children — to completion before +the next leaf starts. That is the entire design. Its consequences: + +- **The reporting burst is impossible by construction.** Each leaf's report is + awaited before the next leaf runs; the interleaving is the control flow, not + a property to enforce or prove around. +- **The page yields in its own `report` handler**: append the row, await one + macrotask, answer. That is the browser's spelling of what the CLI's `write` + already is — print the line, let the terminal show it, run the next test. It + is page code in the impure shell, so no scheduling policy touches shared + code, and there is no constant to guess. (`setTimeout(0)`'s nested 4 ms + clamp costs ~4 ms per test; speed is not a goal, and bun never runs page + code, so the clamp forces nothing.) +- **No frame budget, no yield primitive selection, no batch size.** The longest + blocking task is the longest single proof, with zero tuning. +- **The traversal never fans out**, so the variadic-`all` argument ceiling + ([all-argument-limit](../../effects/todo/all-argument-limit.md)) leaves the + traversal entirely, and the browser interpreter needs only `sandbox` and + `catch`. +- **`fjs t`'s output becomes honest**: lines print after each test in + structural order, and per-leaf durations stop being inflated by concurrent + wall time — today a browser-suite leaf reports ~20 s because ~130 others + share its clock. +- **The cost**: wall clock becomes the sum of awaits instead of the max, and a + proof that secretly depends on a sibling running concurrently deadlocks. + Both are accepted; the second is a timing dependency being flushed out. + `all` and `both` remain as *operations* for programs that want concurrency — + only the traversal stops using them. + +Sequence it as two PRs: **first the sequential traversal in `module.f.mjs` +alone** — console-observable, `fjs t` prints each line as its test finishes, +the full suite run under it is what finds any concurrency-dependent proof, and +the scheduling change is breaking and gets its own changelog entry — **then the +browser port**, which at that point adds no scheduling of any kind. + +#### The pitfall catalog + +Every problem the second attempt met, its cause, and the solution that worked. +The first group is dissolved by the sequential plan; the second group applies +to **any** implementation and the next implementer must not rediscover them; +the third is about method. + +**Dissolved by sequential:** + +1. **The single-task freeze.** Leaves resolve through microtasks, and a + microtask drain never returns to the event loop, so the whole suite ran as + one task — measured in Chromium: **54.7 s**, zero paints, the browser + offering to kill the page. #1759's fix was a frame budget in the + interpreter, which worked (longest task 97–104 ms) and is exactly the + machinery the sequential plan deletes: one macrotask per report gives a + task per test with no budget at all. +2. **The reporting burst.** Under `all`, every child starts before any is + awaited, so each leaf's `report` — a *continuation*, a microtask — queues + behind the entire suite's execution. Measured: first row in the DOM at + **44.3 s of a 50 s run**, 90% of 3,461 rows within ~30 ms of each other. + No budget can fix this — the ordering is the traversal's, and disabling the + budget left the burst unchanged. `fjs t` has it by construction too. +3. **The variadic `all` ceiling.** Every fan-out is a spread, a spread is a + call, and a call has an argument limit: 50,000 siblings build, 100,000 + throw `RangeError` **while building the effect**, before any interpreter + can catch it. Sequential removes every traversal site; + [all-argument-limit](../../effects/todo/all-argument-limit.md) keeps the + rest. +4. **`batchSize = 25` was doing three unnamed jobs**: its `setTimeout` between + waves was the page's only macrotask boundary; awaiting each batch bounded + how far reporting lagged execution; and 25-at-a-time stayed under the + argument ceiling. Nobody chose it for any of them. The lesson is not that + the constant was right — it was indefensible — but that **before deleting + unmotivated code, enumerate what it does, not what it was for.** + +**These survive into any implementation:** + +5. **Enumerating is user code; read once.** A getter runs on every read. A + preflight `collectTests` that only *checked* the tree ran every getter a + second time, and one that succeeded then threw escaped as a synchronous + throw — page stuck in `running`, no report, no completion event. The same + bug recurred one layer down in the same PR: a collision check enumerated + the interpreter's `extra` map and the construction enumerated it again, so + a proxy could hide a key from the check and reveal it to the build. The + rule both times: **read a user value once, and derive everything from that + one reading.** +6. **The page's modules are a list, not a map.** Routing them through a + record-shaped `ModuleMap` let `Object.fromEntries` keep only the last of + two same-labelled modules and report it twice. Two entries with one label + are two runs, in the order passed. +7. **A run must not start before its promise is published.** A leaf executes + synchronously inside its handler, so without a deferral the first proofs + run while `runBrowserProofs` is still building what it returns — a proof + reading `fjsBrowserTestReport` sees the previous run's promise. Defer + everything that runs user code (enumeration included) behind one + `Promise.resolve().then(...)`. +8. **Both ways a run fails as a runner must end in a report.** The error + channel carries what an operation reported; a *rejection* carries what the + interpreter could not dispatch at all, and an unhandled one is a page stuck + in `running` forever. Handle both into the `infrastructure-error` report. +9. **Joins must be linear.** Pairwise immutable concatenation was Θ(N²) + twice — across siblings, then again down a parent/child chain, where + "flatten once at the end" recopies each subtree once per ancestor and is + the same Θ(N²) moved. A sequential fold appends one record at a time and + has no such trap; if records are ever joined, join a whole list at once. +10. **A new exported boundary that its own consumers cast past is not typed.** + `browserRun` began as `(effect: unknown) => Promise` with `any` + casts at both call sites, and its `extra` was `Partial` — advertising a + recovery the dispatcher does not perform (it panics on an unclaimed + command, by design). Make it generic over the effect and its `Result`, + take a complete map, panic on a handler that claims a core operation + (silently letting either side win makes the type or the caller a liar), + and carry handlers by property *descriptor* — `match` looks handlers up + with `getOwnPropertyDescriptor`, so a spread-merge silently drops a + non-enumerable handler the layer's dispatch would have accepted. + +**Method:** + +11. **A proof that observes a coincidence is worse than no proof, because it + is counted as cover.** A proof that the budget yielded watched for *a* + macrotask turn during a run; under the full suite a neighbouring proof + supplies one anyway, so it stayed green with the defect present — sound in + isolation, inert where the project runs it. Assert by *ordering* (a + macrotask cannot run until every pending microtask has) or by structure, + never by observing that the loop turned. And mutation-check under the full + `npm test`, which is the only run that counts — the inert proof passed its + own isolated mutation check. +12. **Measure what the user sees, not a proxy for it.** "392 frames served and + 194 progress updates" was reported as "rows painting as they land"; the + frames were real and dominated by the loading phase, and row count over + time — the thing a person watches — was never sampled. It read 0 until the + end. Sample the artifact itself. +13. **When a decision changes, grep the markdown for the old one.** Seven + review findings on one branch were the same shape: the new answer written + down with the superseded instruction left standing beside it, handing a + future implementer two designs. This file is long precisely so it can be + wrong in one place; keep it saying one thing. + ### Steps -**One step per pull request.** The reverted attempt did the whole issue at once +**One step per pull request.** The first attempt did the whole issue at once — 2646 insertions and 1408 deletions across 35 files — and that is why its arguments could not be separated: a question about scheduling became a question about the port. Each step below stands on its own, leaves both runners working, @@ -133,21 +290,22 @@ and is reviewable without the next one. in [imports, promises and realms](imports-promises-realms.md); the scope rule they rest on is in [browser testing](browser-testing.md). -- [ ] **4. Common effects.** Move `all`, `sandbox` and `catch` out of - `effects/node` into a shared module that `effects/node` re-exports - unchanged, so nothing has to move with them. - - **The list is now settled, by measurement rather than by argument.** - Step 5's interpreter implements exactly those three, so exactly those - three have a second implementer. `await` does not: it belongs to the - *registration* path that external frameworks drive, which no browser - runs. `import` does not: a page loads modules through its own importer. - `now` does not: a browser run measures its own wall clock rather than - dispatching an operation for it. `fetch` does not: nothing in the shared - runner performs one. Those four stay in `effects/node` until something - gives them a second implementer, which is the same rule that let this - list shrink rather than a different one applied to them. - [node-module-layering](../../effects/todo/node-module-layering.md) +- [ ] **4. Common effects.** Move `sandbox` and `catch` out of `effects/node` + into a shared module that `effects/node` re-exports unchanged, so + nothing has to move with them. + + **The list was settled by measurement, then shrank again by design.** + The reverted #1759 interpreter implemented exactly `sandbox`, `catch` + and `all`, so exactly those three had a second implementer. Under the + sequential plan the traversal performs no `all`, so the set with two + implementers is **`sandbox` and `catch`**; `all` stays in `effects/node` + with the registration path. `await` never qualified: it belongs to that + registration path, which no browser runs. `import`, `now` and `fetch` + never qualified either: a page loads modules through its own importer + and reads its own wall clock, in the impure shell where host values + belong. Everything without a second implementer stays in `effects/node` + until something gives it one — the same rule that shrank this list + twice. [node-module-layering](../../effects/todo/node-module-layering.md) carries the same answer. **The expectation this step was written with was wrong, which is why the @@ -193,20 +351,22 @@ and is reviewable without the next one. and this took it. `fjs t` gained the behaviour in the process, which is what made that change worth landing on its own rather than inside the port. -- [x] **5. A browser interpreter** for exactly those operations. - `fjs/effects/browser/module.mjs`: `sandbox`, `catch`, `all`, plus - whatever operations the application adds — for the page, one `report`. - `sandbox` is `effects/node`'s, copied rather than redesigned, because two - runners that disagreed about an awaited leaf would not be one runner. - - This step asked for an interpreter "with no scheduling policy of its - own", and that was half right in a way worth keeping. The *traversal* has - none, which is what the whole issue is about, and nothing about a batch of - proofs belongs here. But an interpreter for a host with a UI thread must - give that thread back, or the host cannot paint — so it carries one - policy, a frame budget charged to every operation it dispatches, which is - a statement about the browser and not about proofs. The Tasks list below - records what that cost to learn. +- [ ] **5. A browser interpreter** for `sandbox` and `catch`, plus whatever + operations the application adds — for the page, one `report`. Nothing + else: a sequential traversal performs no `all`. `sandbox` is + `effects/node`'s, copied rather than redesigned, because two runners + that disagreed about an awaited leaf would not be one runner; `catch` + dispatches to `types/result`'s `tryCatch`, the same helper + `effects/node` uses. + + **No scheduling policy of its own — and this time that holds without a + footnote.** The reverted #1759 interpreter had to carry a frame budget + because the concurrent traversal ran as one microtask drain (catalog + item 1). Sequentially, the page's own `report` handler yields, and the + interpreter's handlers are dumb. Its contract still wants the reverted + attempt's proofs re-landed: a complete non-`Partial` map, a panic on a + colliding or unclaimed command, handlers carried by descriptor, the map + read once (catalog items 5, 8, 10). - [x] **6. One reporter.** The event stream — a leaf landed, a run ended — that both hosts subscribe to. Step 2 gave them the *value*; this gave them the seam it travels through. `Reporter.result` now receives the @@ -224,34 +384,38 @@ and is reviewable without the next one. fold's summed durations, because its leaves run concurrently and the sum only means "how long the run took" for a sequential runner — `RunTotals` documents that. -- [x] **7. One skeleton.** The page's proof-tree walk is deleted and the shared - traversal runs it. `browser.mjs` no longer discovers leaves, applies the - throw expectation, walks return values or counts anything: it supplies a - `Reporter` and an interpreter, and the traversal does the rest. - - **The batching went with it**, as this file said it should be decided - rather than inherited: `batchSize = 25` is gone. Nothing asked for it, no - measurement motivated the constant, and it was the origin of six rounds of - review in the reverted attempt. Deleting the *yield* along with it was the - overshoot — a page that never gives the thread back cannot paint or answer - a click — so the browser interpreter gives it back on a frame budget - instead, which is a number about the host rather than about proofs. The - traversal still schedules nothing, so `fjs t` is unchanged. - - **What the skeleton had to grow**, rather than what the browser had to - keep: the traversal now threads a `RunOutcome` — the folded totals - plus each host's own leaf records, in the walk's order. The browser needs - its report's `results` ordered by structure, and taking them in - completion order would have pinned the scheduler's behaviour instead of - the suite's. `fjs t` answers `void` there and collects nothing, which is - the extension point doing its job. - - **What stayed the page's own, with the reason:** reading a *module's* +- [ ] **7. One sequential skeleton.** Two PRs, in this order. + + **7a. Make the shared traversal sequential**, in `module.f.mjs` alone. + Replace the `all` fan-outs with a sequential fold: one leaf's whole + chain — test, report, children — awaited before the next leaf starts, + for siblings and for modules alike. Console-observable and + console-provable: `fjs t` prints each line as its test finishes, in + structural order, and per-leaf durations become the leaf's own time. + Breaking (scheduling semantics), so it carries its own changelog entry. + Run the full suite under it *in this PR* — a proof that depends on a + sibling running concurrently deadlocks here, where it is cheap to find, + not in the browser port. + + **7b. The page runs the shared traversal** through the step-5 + interpreter. `browser.mjs` stops discovering leaves, applying the throw + expectation, walking return values and counting: it supplies a + `Reporter` whose `result` hands the record to its `report` operation, + and a `report` handler that appends the row and awaits one macrotask. + What the reverted #1759 validated and this PR re-lands: the traversal + threads a `RunOutcome` — folded totals plus each host's leaf records + in the walk's order (`fjs t` answers `void` and collects nothing); the + page's modules stay a *list* entered at a seam for already-collected + leaves, because labels may repeat and an export is enumerated exactly + once, under the page's own guard (catalog items 5, 6); the run starts + only after its promise is published (item 7); and both runner-failure + routes end in the `infrastructure-error` report (item 8). + + **What stays the page's own, with the reason:** reading a *module's* exported tree. The shared walk guards a returned tree through `catch` (see [hostile proof values](hostile-proof-values.md)) but deliberately not the exported one, because there is no leaf to attribute that failure to. `fjs t` panics; the page catches it and reports one failed module. - That asymmetry predates this step and survives it. - [ ] **8. The layout move**, and the website preparation program. @@ -412,24 +576,15 @@ an effect adds an operation for every DOM detail without improving the shared API. Add `fjs/effects/browser/` only after the required operation set is clear; do not create a mirror of `effects/node` merely for directory symmetry. -A shared `all` that starts every child before awaiting any is worth stating as a -contract rather than leaving to each interpreter: a child may wait on something -a later sibling produces, so an interpreter that awaits one child before -starting the next hangs a graph the other host completes. Beyond that, **the -browser gets no scheduling policy of its own until someone reports a problem -with the one `fjs t` has.** If a page turns out to need a task boundary to -paint, that is a separate, measured change with its own issue — and the measure -is a boundary per unit of work, never a tuned count of proofs, because proofs -differ in cost by orders of magnitude. - -That is what happened, and this paragraph turned out to be right on every -count. The problem was reported — a page frozen for the length of a run, with -the browser offering to kill it — the change was measured in a real browser -before and after, and the boundary is per unit of work on a frame budget rather -than per N proofs — every operation the interpreter dispatches, since rendering -a result is work on the same thread as running a proof. It lives in the -interpreter, where a statement about a host belongs, and the traversal still -schedules nothing. +**The traversal is sequential, and that is the scheduling policy — the whole +of it.** The second attempt proved the alternative: a concurrent traversal +needed a frame budget to stay responsive and still delivered its log as one +burst, because no scheduling layer can reorder a continuation ahead of work +already queued (catalog items 1–2). Sequentially, the only scheduling decision +left is the page's one macrotask per report, in the page's own handler. `all` +keeps its start-every-child-before-awaiting-any contract for the programs that +still use it — the registration path, and any program that wants concurrency — +but the traversal is no longer one of them. An executor boundary will still be necessary because the console runner uses the Effects sandbox while a browser catches synchronous throws and awaits @@ -494,16 +649,19 @@ are shared. - [x] Inventory duplicated semantics in `emergent_testing/module.f.mjs` and `emergent_testing/browser.mjs`, and define the smallest shared API. The shared API is `Reporter` and the `RunOutcome` the traversal - answers with; the page supplies the parts and nothing else. + answers with; the page supplies the parts and nothing else. Implemented + and review-validated in the reverted #1759; the design survives as the + plan and re-lands with step 7. - [x] Name the skeleton's parts explicitly — execute a leaf, report a result, link a module — and check that nothing host-specific is left outside one of them. `test`, `result` and `summary` are the parts; linking a module - stays outside the skeleton, which is why `runEntries` exists beside - `runModuleMap`. -- [x] Make the existing `collectTests`/path behavior the single source of truth - for console and browser execution. The page's own walk is deleted; it - calls `collectTests` once, under its own guard, and hands the leaves to - `runEntries`. + stays outside the skeleton, which is why the reverted #1759 gave + `runModuleMap` a sibling entry point taking already-collected leaves, + and step 7b does again. +- [ ] Make the existing `collectTests`/path behavior the single source of truth + for console and browser execution. Done in the reverted #1759 — the + page's walk was deleted, `collectTests` called once under the page's own + guard — and re-lands with step 7b. - [x] Share the test-name format, and prove both runners name the same leaf identically. The browser report carries a `name` built by `fmtImport`, and `nameMatchesTheConsoleRunner` pins it to that function rather than to a @@ -515,10 +673,11 @@ are shared. still each host's own. - [x] Decide whether browser import/time/yield/publication justify `fjs/effects/browser/`; document the decision before adding operations. - They do not: the interpreter implements `sandbox`, `catch` and `all` and - nothing else — import, time and publication are the page's, in its - impure shell. Recorded in that module and in - `effects/todo/node-module-layering.md`. + They do not: the reverted #1759 interpreter needed `sandbox`, `catch` + and `all` and nothing else, and the sequential plan drops `all` too — + import, time, yield and publication are all the page's, in its impure + shell. Recorded in + [node-module-layering](../../effects/todo/node-module-layering.md). - [ ] Move static proof discovery and `_browser-suite.mjs` generation into `fjs/website/module.f.mjs`; extend `fjs/effects/node/` only for a concrete missing capability and prove the real and virtual interpretations. @@ -533,12 +692,13 @@ are shared. interpretation, DOM rendering, and browser publication. - [ ] Update the generated website entry and browser-test application imports to the new module paths. -- [x] Prove both runners produce equivalent paths, throw outcomes, recursive - test counts, and normalized failures from the same fixtures. They now - share the code that decides all four, and `nameMatchesTheConsoleRunner`, +- [ ] Prove both runners produce equivalent paths, throw outcomes, recursive + test counts, and normalized failures from the same fixtures. The + existing `nameMatchesTheConsoleRunner`, `expectedThrowStatusMatchesTheSharedOne` and - `normalizedResultMatchesTheSharedOne` assert against the console - runner's own functions rather than against a spelling. + `normalizedResultMatchesTheSharedOne` already assert against the console + runner's own functions; step 7b makes the four properties shared code + rather than agreeing implementations. - [x] Record every behaviour the browser file has today and the shared core will not keep, as an issue, before the sharing change merges. Two: the `batchSize = 25` yielding — whose *constant* was the mistake and whose @@ -547,63 +707,20 @@ are shared. [hostile-proof-values](./hostile-proof-values.md). - [ ] Close each of those issues for both runners at once, so the two stay in sync rather than drifting from the day the core is shared. -- [x] Decide where a browser run gives the thread back. **The browser - interpreter, on a frame budget charged to every operation it dispatches** - — 8 ms, what a 60 Hz frame leaves for script — not a count of proofs, and - not the traversal, which stays free of scheduling so `fjs t` is - untouched. - - This was got wrong three times before it was measured, and all three are - worth keeping. First, deleting `batchSize = 25` was read as deleting the - whole idea: the constant was indefensible — twenty-five trivial leaves - are nothing and twenty-five heavy ones are still a freeze — but the - `setTimeout` between waves was the only thing giving the page a turn. - Without it the whole suite is one task: leaves resolve through - microtasks, and a microtask drain never returns to the event loop, so - nothing paints and no click is answered until the run ends. Measured in - Chromium on this repo's own browser suite: a single **54.7 s** task, zero - rows painted, and the browser offering to kill the page. - - Second, the fix's first shape awaited the budget *before* each leaf, and - changed nothing. A leaf runs synchronously inside its handler, which is - what makes `all`'s children start one after another as each previous leaf - finishes; await anything first — even a resolved promise — and every - handler asks whether the slice is spent at the same instant, before any - leaf has run. All see room, none yields. The check has to answer without - awaiting when there is room, which is why it answers `null` rather than a - settled promise. - - Third — and the proof for it was wrong before the code was — only - `sandbox` was charged, which holds until a page is cheap to test and - expensive to render: a hundred trivial leaves start inside one - slice, and the hundred renders that follow drain with no turn given back. - Whatever the runner dispatches runs on the host's thread, so every - operation is charged now — the ones a host adds included, because that is - the host's own work. - - The first proof for that watched for *a* turn during a run, and was inert - where the project runs it: under the whole suite another proof hands the - loop a boundary, so the check passed with the defect present. Its - replacement asserts by ordering instead — a macrotask cannot run until - every pending microtask has, so racing the dispatch against a chain of - microtasks says which kind of boundary it waited for, whatever else the - process is doing. **A proof that observes a coincidence is worse than no - proof**, because it is counted as cover. - - After: longest task **97–104 ms**, none of the 3,461 rows waiting for the - end, wall clock 50.9 s against 52.8 s — the yields cost 0.38 ms each and - the budget asks for few of them. What is left blocking is a single proof - that computes without stopping, which nothing at this layer can split. - `all` was not the place to put this: it must start every child before - awaiting any, so pausing between children hangs a graph whose child waits - on a later sibling, which is the deadlock the reverted attempt hit. -- [ ] Prove `runBrowserProofs`'s `infrastructure-error` branch — the run's own - failure, as opposed to any proof's. It is the one branch of the page with - no proof, and reaching either half of it (an operation reporting through - the error channel, or one the interpreter cannot dispatch at all, which - rejects) needs an effect the public entry point gives no way to inject. - `effects/browser/proof.mjs` pins the interpreter's half — a command no - handler claims rejects — so what is left is the page's own guard. +- [x] Decide where a browser run gives the thread back. **One macrotask per + report, in the page's own `report` handler** — the sequential plan's + answer, superseding the reverted #1759's frame budget. The full story + of how the frame budget was got wrong three times before being measured, + and why even measured-correct it could not fix the reporting burst, is + the pitfall catalog above (items 1, 2, 4, 11, 12). +- [ ] Prove `runBrowserProofs`'s `infrastructure-error` branch when step 7b + re-lands it — the run's own failure, as opposed to any proof's. In the + reverted #1759 neither half of the guard (an operation reporting through + the error channel, or one the interpreter cannot dispatch, which + rejects) was reachable through the public entry point, so the guard had + no proof; review established by mutation that removing it stayed green. + Step 8's split of the page into `module.f.mjs`/`module.mjs` is the seam + that makes it reachable. ### Related diff --git a/fjs/emergent_testing/types.ts b/fjs/emergent_testing/types.ts index b96686733..cd56f7150 100644 --- a/fjs/emergent_testing/types.ts +++ b/fjs/emergent_testing/types.ts @@ -3,8 +3,7 @@ */ import type { Effect, Operation } from '../effects/types.ts' -import type { List } from '../types/list/types.ts' -import type { IoChannel, OpResult, SandboxResult } from '../effects/node/types.ts' +import type { IoChannel, SandboxResult } from '../effects/node/types.ts' /** A zero-argument test function whose return value may contain sub-tests. */ export type TestFn = () => unknown @@ -126,21 +125,6 @@ export type BrowserTestReport = { readonly results: readonly _BrowserTestResult[] } -/** - * The browser page's own reporting operation: the shared traversal hands it one - * leaf record, and the page's interpreter renders it and answers it back. - * - * It is an operation rather than a callback because the traversal is pure — - * rendering a row is a side effect, and the effect system is where those go. - * One operation for the whole event is enough: making each DOM detail its own - * operation would grow the browser's op-set without making the shared API any - * better. - * - * @internal - */ -export type _BrowserReport = - readonly['report', (r: _BrowserTestResult) => OpResult<_BrowserTestResult>] - /** * Loads one proof module by its source path for the browser runner. * @@ -192,7 +176,7 @@ export type RunTotals = { * tail that reports it — free of a parameter every caller would have to thread * through unchanged. */ -export type Reporter = { +export type Reporter = { /** * A leaf landed. The first argument is the shared {@link TestResult} — the * runner builds it with `testResult` before notifying, so a reporter @@ -200,58 +184,12 @@ export type Reporter = { * The raw `SandboxResult` and the throw expectation travel with it because * describing a *thrown value* is each host's part (see {@link TestResult}), * and the description needs the value. - * - * **It answers `R`, the host's own record of the leaf**, and the traversal - * keeps those in {@link RunOutcome}. That is how a host gets its results in - * *structural* order — a parent before the children its return value - * produced, siblings in declaration order — rather than in the order they - * happened to finish. The distinction is not academic: leaves run - * concurrently, so completion order belongs to the scheduler, and a report - * built from it would be pinning an engine's behaviour rather than the - * suite's. - * - * `fjs t` answers `void`, having already written its line by the time it - * returns; the browser answers the record its wire report is built from. */ - readonly result: (t: TestResult, r: SandboxResult, throws: boolean) => Effect + readonly result: (t: TestResult, r: SandboxResult, throws: boolean) => Effect /** The run ended, with the totals folded from every leaf that landed. */ readonly summary: (totals: RunTotals) => Effect readonly test: (file: string, path: Path, set: TestEntry) => Effect, IoChannel> } -/** - * What a run produced: its folded {@link RunTotals}, and every leaf record the - * reporter answered, in the traversal's own order. - * - * The two are not redundant. The totals are a fold and cannot be rebuilt from a - * list a host chose to leave empty (`fjs t` collects `void`), and the list is - * ordered by the walk rather than by when each leaf settled. - */ -export type RunOutcome = { - readonly totals: RunTotals - readonly results: readonly R[] -} - -/** - * What the walk itself accumulates, before the run answers a {@link RunOutcome}. - * - * The records are a `List` rather than an array because joining two arrays - * copies both: a parent that joined its children's records would recopy every - * descendant at every level, and the walk would cost more the deeper it went. - * Joining `List`s is a node that names them, and `toArray` walks the whole - * rope once, at the end. - * - * Each record is **boxed**, because a `List` reads a bare array or function in - * an element position as a sub-list to splice. `R` is the host's own leaf - * record and this module has no business restricting what it may be, so it - * never puts one in that position. - * - * @internal - */ -export type _RunAcc = { - readonly totals: RunTotals - readonly results: List<{ readonly value: R }> -} - /** @internal */ export type _TestAndPath = readonly [Path, TestEntry] From cefa0d38f8c602d44b7fcc1f23ac45fbbbbeda17 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:18:16 +0000 Subject: [PATCH 289/370] ci: validate the whole compiler pin, not its first character MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startsWith('=') was the wrong shape rather than the wrong constant: `=7.x`, `=7.0`, `=7.0.2 || 8.x` all pass a prefix test and are all ranges npm resolves against the registry, which is exactly what running without a checkout is meant to rule out. The pin must now be `=MAJOR.MINOR.PATCH` and nothing else — three dot-separated runs of digits after the sign. No regex; the repository forbids them and the check is two lines without one. It rejects a prerelease pin too, which is stricter than npm needs. That is the right way to be wrong here: the cost is the job disappearing from ci.yml, a visible diff in review, rather than a check that silently stops meaning anything. The validator caught the proof's own fixture, which used =9.9.9-run. Nine rejection cases now cover the shapes that pass a prefix test, which is also what keeps branch coverage at 100%. This closes the class rather than patching another instance of it: the previous three rounds each fixed one spelling of "not really pinned". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/module.f.mjs | 31 +++++++++++++++++++++++++------ fjs/ci/package/proof.f.mjs | 2 +- fjs/ci/proof.f.mjs | 9 ++++++++- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/fjs/ci/module.f.mjs b/fjs/ci/module.f.mjs index d4f4a83fd..afa41f202 100644 --- a/fjs/ci/module.f.mjs +++ b/fjs/ci/module.f.mjs @@ -63,6 +63,30 @@ const canonicalJobs = (rust, pin) => ({ 'nix-flakes': nixFlakeJob, }) +/** @type {(s: string) => boolean} */ +const digits = s => s !== '' && [...s].every(c => c >= '0' && c <= '9') + +/** + * `=MAJOR.MINOR.PATCH` and nothing else. + * + * Anything npm reads as a *range* — `^7.0.0`, `=7.x`, `=7.0`, `=7.0.2 || 8.x` — + * lets a later registry release change this check's verdict with no change + * here, which is the one thing running it without a checkout is meant to + * prevent. A leading `=` is not enough on its own: it can prefix a range. So + * the whole value is validated rather than its first character. + * + * A prerelease pin is rejected too. That is stricter than npm needs, and the + * cost of being wrong is the job disappearing from `ci.yml` — a visible diff in + * review — rather than a check that silently stops meaning anything. + * + * @type {(pin: string) => boolean} + */ +const exact = pin => { + if (!pin.startsWith('=')) { return false } + const parts = pin.slice(1).split('.') + return parts.length === 3 && parts.every(digits) +} + /** * The compiler the packed-package check installs, read out of the project's own * `package.json` rather than restated anywhere. A second copy could disagree @@ -84,12 +108,7 @@ const compilerPin = text => { const dev = root.devDependencies if (typeof dev !== 'object' || dev === null || dev instanceof Array) { return undefined } const pin = dev.typescript - // Only an exact pin. A range such as `^7.0.0` lets a later registry release - // change this check's verdict with no change here, which is the one thing - // running it without a checkout is meant to prevent. Relaxing the pin drops - // the job from the generated workflow, which is a visible diff rather than - // a quiet loss of checking. - return typeof pin === 'string' && pin.startsWith('=') ? pin : undefined + return typeof pin === 'string' && exact(pin) ? pin : undefined } /** @type {(setup: Setup) => Effect} */ diff --git a/fjs/ci/package/proof.f.mjs b/fjs/ci/package/proof.f.mjs index 358c2fe12..c73ca73a2 100644 --- a/fjs/ci/package/proof.f.mjs +++ b/fjs/ci/package/proof.f.mjs @@ -5,7 +5,7 @@ import { assert, assertEq } from '../../asserts/module.f.mjs' // A pin no configuration anywhere holds, so an assertion that finds it can only // have found the value passed in. Importing the generator's own constant would // compare it with itself and hold for any value. -const pin = /** @type {const} */ ('=1.2.3-proof') +const pin = /** @type {const} */ ('=1.2.3') const job = packageCheckJob(pin) diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index faa8eddbb..ea035434f 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -72,7 +72,7 @@ const flake = (state, id) => // The packed-package check is generated only when the project pins a compiler, // so the shared fixture supplies one. A pin no configuration holds, so an // assertion that finds it found the value that came from here. -const runPin = /** @type {const} */ ('=9.9.9-run') +const runPin = /** @type {const} */ ('=9.9.9') const runPackageJson = `{"name":"other-package","devDependencies":{"typescript":"${runPin}"}}` @@ -296,6 +296,13 @@ export const proof = { '{"name":"p","devDependencies":{}}', // no typescript '{"devDependencies":{"typescript":"^7.0.0"}}', // a range, not a pin '{"devDependencies":{"typescript":"7.0.2"}}', // bare, still not exact + '{"devDependencies":{"typescript":"=7.x"}}', // `=` prefixing a range + '{"devDependencies":{"typescript":"=7.0"}}', // two segments is a range + '{"devDependencies":{"typescript":"=7.0.2.1"}}', // four is not a version + '{"devDependencies":{"typescript":"=7.0.2 || 8.x"}}', // a union + '{"devDependencies":{"typescript":"=7.0.beta"}}',// a non-numeric segment + '{"devDependencies":{"typescript":"=7..2"}}', // an empty segment + '{"devDependencies":{"typescript":"="}}', // nothing after the sign ])) { const [state, result] = virtual(makeState(false, packageJson))(ci({ nodeExtra: () => [] })) assertEq(exitCode(result), 0) From 80ed2d98bff17fe6372c7d15a6ad9c65004c843b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:18:46 +0000 Subject: [PATCH 290/370] todo: the plan says one thing everywhere Five review findings on the plan itself, four of them the catalog's own item 13 committed inside the catalog's PR: - node-module-layering's proposal still moved `All`/`Await` and never named `Catch`; the sandbox row now carries `Catch`, names itself as what step 4's "shared module" resolves to, and `all`'s move rests on the layering argument alone. - "a sequential fold appends one record at a time and has no such trap" was wrong: sequential changes execution order, not concatenation cost; the port keeps the rope. - the infrastructure-error proof was ordered before its seam exists; the guard lands unproven with 7b, and step 8 closes the task. - "shared semantics first, with fjs t unchanged" contradicted the 7a/7b order once the plan itself became a scheduling change; the paragraph now endorses one argument per PR and says why the order flipped. - all-argument-limit narrowed the ceiling to leaves while its own table said the module spread fails independently; it is per fan-out. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/all-argument-limit.md | 11 ++-- fjs/effects/todo/node-module-layering.md | 13 +++-- .../todo/share-browser-console-runner.md | 55 ++++++++++++------- 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/fjs/effects/todo/all-argument-limit.md b/fjs/effects/todo/all-argument-limit.md index 09457c2c7..7fef3a42c 100644 --- a/fjs/effects/todo/all-argument-limit.md +++ b/fjs/effects/todo/all-argument-limit.md @@ -32,11 +32,12 @@ recover from it and no `catch` operation is in the path. `fjs t` panics; the bro reports one `infrastructure-error` because it guards the run's own failure, which is the guard working as intended but not an answer. -This is a limit on *one module's* sibling leaves rather than on a suite: modules are -themselves siblings, so a suite of any size passes as long as no single module holds more -than a few tens of thousands of leaves. Nothing in this repository is close — the browser -suite is 3,461 leaves across 138 modules — so this is a real ceiling rather than a live -problem, and it is recorded rather than fixed for that reason. +The ceiling applies **per fan-out**, and a run has two: one module with too many sibling +leaves breaks the inner spread, and a run with too many *modules* breaks the outer one in +`runModuleMap` — the independence the table above states. Nothing in this repository is +close to either — the browser suite is 3,461 leaves across 138 modules, three orders of +magnitude under both — so this is a real ceiling rather than a live problem, and it is +recorded rather than fixed for that reason. The browser runner avoids it accidentally: it fans out in batches of 25 through `Promise.all`, so it never spreads more than 25 arguments — a protection nobody asked for diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index 925c6c354..d0a5a169e 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -51,7 +51,7 @@ provides*. Proposed destinations: | Moves to | Contents | |---|---| | `fjs/effects/all/module.f.mjs` | `All`, `all`, `allOk`, `both`, and `allVoid`/`allReduce` when they land | -| `fjs/effects/sandbox/module.f.mjs` | `Sandbox`, `SandboxResult`, `sandbox`, `Await`, `awaitIfPromise` — the "run foreign code and observe what happened" pair | +| `fjs/effects/sandbox/module.f.mjs` | `Sandbox`, `SandboxResult`, `sandbox`, `Await`, `awaitIfPromise`, and `Catch`/`catch_` (landed after this table was written) — the "run foreign code and observe what happened" family. This row is what [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) step 4's "shared module" resolves to: a browser gives `Sandbox` and `Catch` their second implementer; `Await` moves on this issue's layering argument alone, since it belongs to the registration path no browser runs | | `fjs/effects/console/module.f.mjs` | `Read`, `Write`, `ReadConsoles`, `WriteConsoles`, `Console`, `log`, `error`, `readLine`, `errorExit`, and a **new named `Std`** (see below) | | `fjs/effects/test/module.f.mjs` | `Test`, `TestFn`, `TestContext`, `test` — registration with an external framework, not I/O | | stays in `fjs/effects/node` | `Fs` and its members, `Http`, `Forever`, `RandomInt`, `isNotFound`, `Env`, `Engine`, `NodeOp`, `NodeProgramOptions`, `Program`, `NodeProgram`, `NodeOperationMap` | @@ -87,9 +87,11 @@ Judgement calls worth deciding explicitly rather than by accident: the three gained a second implementer, and DESIGN.md §4 keeps them here until one does. The sequential plan that replaced that attempt (see share-browser-console-runner) shrinks the measured set once more: a - sequential traversal performs no `all`, so the operations with a second - implementer become `sandbox` and `catch` alone, and `all` stays here with - the registration path. + sequential traversal performs no `all`, so the operations a browser gives a + second implementer are `sandbox` and `catch` alone. That takes `all` out of + *step 4's* motivation, not out of this issue's: its move to `effects/all` + above rests on the layering argument, and its implementers stay the Node + runners and the registration path. Worth recording, because the earlier expectation written here was wrong about two of them: "a browser proof run needs a clock and dynamic import" is true of @@ -247,7 +249,8 @@ Judgement calls worth deciding explicitly rather than by accident: `allOk` is the ok-channel wrapper over `all` and belongs with it; [allvoid-combinator](./allvoid-combinator.md) builds on it, so leaving it behind would make `effects/all` import from `effects/node`. -- [ ] Move `Sandbox` / `Await` and helpers to `fjs/effects/sandbox/module.f.mjs`. +- [ ] Move `Sandbox` / `Await` / `Catch` and helpers to + `fjs/effects/sandbox/module.f.mjs`. - [ ] Move the console family to `fjs/effects/console/module.f.mjs`, add the named `Std` type there as `RequiredMap`, point `NodeProgramOptions.std` at it, and narrow `csiWrite` to take `Std` diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index b86b6d773..86eb1e520 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -105,13 +105,19 @@ browser could have is an issue, not something to introduce inside a port. A behaviour the port cannot preserve is a finding to record before it merges, not a silent divergence to explain in review. -**Keep the change reviewable.** The first attempt was 2646 insertions and 1408 -deletions across 35 files in one PR — a move, a rewrite, a new effects layer, a -new host interpreter and a scheduling invention at once, which is why the -scheduling argument could not be separated from the sharing argument. Sequence -it: the shared semantics first, with `fjs t` unchanged in behaviour and the -browser file only calling into it; the layout moves after; anything genuinely -new last, on its own. +**Keep the change reviewable: one argument per PR.** The first attempt was +2646 insertions and 1408 deletions across 35 files in one PR — a move, a +rewrite, a new effects layer, a new host interpreter and a scheduling +invention at once, which is why the scheduling argument could not be separated +from the sharing argument. The sequence that keeps them separate is the one +the plan below orders: the scheduling change first, alone, in the console +runner where it is observable and provable without any port (step 7a); then +the port, which changes no behaviour beyond calling the shared code (step 7b); +the layout moves after. An earlier version of this paragraph said "shared +semantics first, with `fjs t` unchanged in behaviour" — right about +separation, wrong about order once the plan itself became a scheduling change: +porting first would have moved the browser onto semantics about to change +under it. ### The second attempt (#1759), and the plan it simplified to @@ -226,11 +232,15 @@ the third is about method. channel carries what an operation reported; a *rejection* carries what the interpreter could not dispatch at all, and an unhandled one is a page stuck in `running` forever. Handle both into the `infrastructure-error` report. -9. **Joins must be linear.** Pairwise immutable concatenation was Θ(N²) - twice — across siblings, then again down a parent/child chain, where - "flatten once at the end" recopies each subtree once per ancestor and is - the same Θ(N²) moved. A sequential fold appends one record at a time and - has no such trap; if records are ever joined, join a whole list at once. +9. **Joins must be linear, and sequential does not grant that for free.** + Pairwise immutable concatenation was Θ(N²) twice — across siblings, then + again down a parent/child chain, where "flatten once at the end" recopies + each subtree once per ancestor and is the same Θ(N²) moved. The fix that + worked was a rope: joining is one node naming both sides, `toArray` walks + it once where the run ends. A sequential fold changes execution order, not + concatenation cost — an immutable `[...acc, r]` append copies the prefix + every iteration and is the same Θ(N²) — so the port keeps the rope, or + another accumulator that is demonstrably linear. 10. **A new exported boundary that its own consumers cast past is not typed.** `browserRun` began as `(effect: unknown) => Promise` with `any` casts at both call sites, and its `extra` was `Partial` — advertising a @@ -713,14 +723,19 @@ are shared. of how the frame budget was got wrong three times before being measured, and why even measured-correct it could not fix the reporting burst, is the pitfall catalog above (items 1, 2, 4, 11, 12). -- [ ] Prove `runBrowserProofs`'s `infrastructure-error` branch when step 7b - re-lands it — the run's own failure, as opposed to any proof's. In the - reverted #1759 neither half of the guard (an operation reporting through - the error channel, or one the interpreter cannot dispatch, which - rejects) was reachable through the public entry point, so the guard had - no proof; review established by mutation that removing it stayed green. - Step 8's split of the page into `module.f.mjs`/`module.mjs` is the seam - that makes it reachable. +- [ ] Prove `runBrowserProofs`'s `infrastructure-error` branch — the run's + own failure, as opposed to any proof's — **at step 8, not step 7b.** + The order is forced, and worth stating so 7b is not directed to do the + impossible: the guard itself must land with 7b, because a page must + never stay in `running`, but neither half of it (an operation reporting + through the error channel, or one the interpreter cannot dispatch, + which rejects) is reachable through the public entry point — the + reverted #1759 proved that by mutation, removing the guard stayed + green. So 7b lands the guard and records it unproven, and step 8's + split of the page into `module.f.mjs`/`module.mjs` is the seam that + makes it reachable and closes this task. Widening the public API just + to reach the branch was considered and rejected: testing a thing by + deforming it. ### Related From 71cbd2dc03cfac6c310e1183b52cad528cfb2fab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:18:58 +0000 Subject: [PATCH 291/370] changelog: state the order the readers actually answer in The entry said illegal absence is settled before the length bound; the bound runs first, so an oversized value that also lacks a required member reports the container-level error. Reported by Codex on the pull request. --- changelog/unreleased/1766.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/changelog/unreleased/1766.md b/changelog/unreleased/1766.md index a4bcd8324..68cd8f501 100644 --- a/changelog/unreleased/1766.md +++ b/changelog/unreleased/1766.md @@ -1,5 +1,5 @@ -- `rtti`: `validate` and `parse` decide a closed tuple's or struct's member - presence and settle any illegal absence, then bound it by length, before - reading any member — so an `or` of two arities no longer walks shared - operands once per arm. Acceptance is unchanged; a container-level or - absent-member error now wins over an earlier member's. +- `rtti`: `validate` and `parse` answer a closed tuple's or struct's structure + before reading any member — presence recorded, length bound applied, then + illegal absence settled — so an `or` of two arities no longer walks shared + operands once per arm. Acceptance is unchanged; a container-level error now + wins over an absent member's, and both over an earlier member's. From 727cc73491a96a7771f94cb1ee120251d483240d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:23:22 +0000 Subject: [PATCH 292/370] todo: linking is before the skeleton, not a part of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attempt-1 prose listed "where a module is linked" among the parts the skeleton calls; building the thing settled the boundary the other way, and the task list already said so. The skeleton accepts linked modules — fjs t loads through its module map, the page through its own importer with its own loading UI — and neither shape fits a part the other host could supply. The paragraph now records the original claim and why it moved. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/share-browser-console-runner.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 86eb1e520..2b2d7a70f 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -34,11 +34,19 @@ the code was. **The order of work is the deliverable here, not just the final shape.** See [DESIGN.md §4, "Follow the example"](../../../DESIGN.md). **One skeleton, with named parts.** The thing to share is the *runner itself*: -the order in which modules are linked, leaves discovered, bodies executed, -throws inverted, results counted and the run concluded. Both hosts run that same -skeleton. Everything host-specific is a **part** the skeleton calls at a place it -names — where the leaf body is executed, where a result is reported, where a -module is linked — and a part is where a browser is allowed to be a browser. +the order in which leaves are discovered, bodies executed, throws inverted, +results counted and the run concluded. Both hosts run that same skeleton. +Everything host-specific is a **part** the skeleton calls at a place it names — +where the leaf body is executed, where a result is reported — and a part is +where a browser is allowed to be a browser. This paragraph originally listed +"where a module is linked" among the parts, and building it settled the +boundary the other way: **linking happens before the skeleton, in host code, +and the skeleton accepts linked modules** — `fjs t` loads through its module +map, the page through its own importer with its own loading UI, and neither +shape fits a part the other host could supply. The tasks below record the +consequence: the runner exposes an entry point for a host that enumerates its +own modules, and enumerating a module's export is that host's own guarded +read. That gives exactly two ways to accommodate a host, both additive: change *that host's part*, or *improve the skeleton so every host benefits*. There is no From 15b638475d9e7902af58c166578cd7f94449bd72 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:27:45 +0000 Subject: [PATCH 293/370] todo: the duration gap outlives the concurrency that named it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6's note and RunTotals's JSDoc both explain wall-clock-vs-summed duration by leaves running concurrently, which stops being true at step 7a. The gap itself survives — wall clock carries what is between the leaves, per-report yields and module loading — so step 7b now includes updating the published JSDoc, and step 6's note says why the reasoning changes while the distinction does not. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/share-browser-console-runner.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 2b2d7a70f..fdf378485 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -399,9 +399,12 @@ and is reviewable without the next one. the raw `SandboxResult` still travels next to the `TestResult`, because describing a *thrown value* is each host's part (step 2's finding); and the browser report's own `duration` stays wall-clock rather than the - fold's summed durations, because its leaves run concurrently and the sum - only means "how long the run took" for a sequential runner — - `RunTotals` documents that. + fold's summed durations. When this landed the reason was concurrency; + under the sequential plan the two draw closer but stay distinct — wall + clock also carries what is *between* the leaves: the per-report yields, + module loading, everything the run does that no leaf owns. Step 7b + updates this reasoning where it is published, in `RunTotals`'s JSDoc + (`types.ts`), which today still explains the gap by concurrency. - [ ] **7. One sequential skeleton.** Two PRs, in this order. **7a. Make the shared traversal sequential**, in `module.f.mjs` alone. @@ -420,6 +423,10 @@ and is reviewable without the next one. expectation, walking return values and counting: it supplies a `Reporter` whose `result` hands the record to its `report` operation, and a `report` handler that appends the row and awaits one macrotask. + Update `RunTotals`'s JSDoc in `types.ts` here too: it explains + wall-clock-vs-summed-duration by leaves running concurrently, and under + this step the gap is the run's own overhead — per-report yields, module + loading — not concurrency (step 6's note carries the same correction). What the reverted #1759 validated and this PR re-lands: the traversal threads a `RunOutcome` — folded totals plus each host's leaf records in the walk's order (`fjs t` answers `void` and collects nothing); the From c12fe18cd902675dd801a3afe74ccfd9e7af3eae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:31:05 +0000 Subject: [PATCH 294/370] todo: one home for `all`, and loading is outside the timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 still kept `all` in effects/node after node-module-layering was reconciled the other way — half a fix is a fresh contradiction. `all` is not step 4's to move: its home is node-module-layering's question, which moves it to effects/all on the layering argument. And module loading never was in the duration gap: the page's timer starts after its imports settle. The gap is what a run does between leaves — per-report yields, enumeration, joining — in both places that explain it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/share-browser-console-runner.md | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index fdf378485..2fc365a24 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -316,8 +316,11 @@ and is reviewable without the next one. The reverted #1759 interpreter implemented exactly `sandbox`, `catch` and `all`, so exactly those three had a second implementer. Under the sequential plan the traversal performs no `all`, so the set with two - implementers is **`sandbox` and `catch`**; `all` stays in `effects/node` - with the registration path. `await` never qualified: it belongs to that + implementers — and this step's whole scope — is **`sandbox` and + `catch`**. `all` is not this step's to move at all: its home is + [node-module-layering](../../effects/todo/node-module-layering.md)'s + question, which moves it to `effects/all` on the layering argument, with + the Node runners and the registration path as its implementers. `await` never qualified: it belongs to that registration path, which no browser runs. `import`, `now` and `fetch` never qualified either: a page loads modules through its own importer and reads its own wall clock, in the impure shell where host values @@ -402,9 +405,11 @@ and is reviewable without the next one. fold's summed durations. When this landed the reason was concurrency; under the sequential plan the two draw closer but stay distinct — wall clock also carries what is *between* the leaves: the per-report yields, - module loading, everything the run does that no leaf owns. Step 7b - updates this reasoning where it is published, in `RunTotals`'s JSDoc - (`types.ts`), which today still explains the gap by concurrency. + enumeration, joining, everything the run does that no leaf owns. (Not + module loading: the page's timer starts after its imports settle, and + keeps doing so.) Step 7b updates this reasoning where it is published, + in `RunTotals`'s JSDoc (`types.ts`), which today still explains the gap + by concurrency. - [ ] **7. One sequential skeleton.** Two PRs, in this order. **7a. Make the shared traversal sequential**, in `module.f.mjs` alone. @@ -425,8 +430,10 @@ and is reviewable without the next one. and a `report` handler that appends the row and awaits one macrotask. Update `RunTotals`'s JSDoc in `types.ts` here too: it explains wall-clock-vs-summed-duration by leaves running concurrently, and under - this step the gap is the run's own overhead — per-report yields, module - loading — not concurrency (step 6's note carries the same correction). + this step the gap is what the run does *between* leaves — per-report + yields, enumeration, joining — not concurrency. Module loading is not + part of it: the page's timer starts after its imports have settled, and + stays there (step 6's note carries the same correction). What the reverted #1759 validated and this PR re-lands: the traversal threads a `RunOutcome` — folded totals plus each host's leaf records in the walk's order (`fjs t` answers `void` and collects nothing); the From 5f90cda8da3266229afae04802160600f95ed3b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:33:33 +0000 Subject: [PATCH 295/370] ci: let tsc enumerate the packed declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package check walked the artifact with `find`, guarded the result with `test -s`, and fed it to `tsc` through `xargs -0`. A `tsconfig.json` with an `include` pattern does all three, and does them better: - No path passes through the shell, so a space or a quote in a directory name has nothing to survive. The `-print0`/`xargs -0` pairing existed only to carry bytes safely across that boundary; there is no boundary now. - An empty match is TS18003, which names the pattern that found nothing. The `test -s` guard could only say that a file was empty. - `.d.cts` and `.d.mts` need no enumerating: `include` selects every extension the compiler reads, so a package shipping a form we did not list is covered rather than skipped. Six steps become five, and three external tools become none. Root AGENTS.md §6 asks for an established tool that parses what it checks rather than a pattern that approximates one; here the tool that does the checking also does the finding. `exclude` is emptied because the default excludes `node_modules`, the only place the artifact exists. `skipLibCheck` is stated rather than left at its default — it is the one option whose flip leaves the job green having opened nothing, and the proof pins it. `fjs/ci/README.md` said the built-in command does not read `package.json`. Since the compiler pin moved out of the config constant, it does; the paragraph now says what it reads and what an inexact pin costs. Verified by running the emitted steps verbatim against real tarballs: 396 declarations checked, identical to the previous pipeline; a declaration referencing a module the tarball does not carry fails with TS2307; the same break passes silently under skipLibCheck true; a `.d.cts` under a directory named `we"ird dir` is enumerated; a package shipping no declarations exits 2 with TS18003. 3479/3479, coverage 100%, workflow regenerates clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 9 +++----- fjs/ci/README.md | 12 ++++++++--- fjs/ci/package/module.f.mjs | 42 +++++++++++++++++++++++++++---------- fjs/ci/package/proof.f.mjs | 31 +++++++++++++-------------- 4 files changed, 57 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5ae37ef6..296df9f00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -542,19 +542,16 @@ "run": "npm init -y > /dev/null" }, { - "run": "npm install \"packed@file:$(ls *.tgz)\"" + "run": "npm install \"packed@file:$(echo *.tgz)\"" }, { "run": "npm install \"typescript@=7.0.2\"" }, { - "run": "find node_modules/packed \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) -print0 > declarations" + "run": "echo '{\"include\":[\"node_modules/packed/**/*\"],\"exclude\":[],\"compilerOptions\":{\"module\":\"nodenext\",\"target\":\"esnext\",\"strict\":true,\"noEmit\":true,\"skipLibCheck\":false}}' > tsconfig.json" }, { - "run": "test -s declarations" - }, - { - "run": "xargs -0 npx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false < declarations" + "run": "npx tsc" } ] }, diff --git a/fjs/ci/README.md b/fjs/ci/README.md index d22b521a7..ffbaec354 100644 --- a/fjs/ci/README.md +++ b/fjs/ci/README.md @@ -134,9 +134,15 @@ package has been installed. Custom projects that need different runtime setup st should use `fjs run ` and call `ci(setup)` directly instead of modifying the built-in command. -The built-in command does not read `package.json` to customize generated steps. -The FunctionalScript package version used by generated Node, Deno, and Bun smoke -tests is pinned in `config/module.f.mjs`, not read from `package.json`. +The built-in command reads `package.json` for one thing: `devDependencies.typescript`. +An exact version there — `=7.0.2`, not `^7.0.0` — generates the `package-check` +job and is the compiler that job installs, because a job with no checkout has no +lockfile to resolve a range against. Anything else, including no entry at all, +generates no `package-check` job. + +Nothing else in `package.json` reaches the generated steps. The FunctionalScript +package version used by generated Node, Deno, and Bun smoke tests is pinned in +`config/module.f.mjs`, not read from `package.json`. ## Customisation diff --git a/fjs/ci/package/module.f.mjs b/fjs/ci/package/module.f.mjs index 2b6ae154c..673f07a77 100644 --- a/fjs/ci/package/module.f.mjs +++ b/fjs/ci/package/module.f.mjs @@ -20,7 +20,32 @@ export const packageCheckJobId = /** @type {const} */ ('package-check') // directory name. Nothing here self-references; revisit if that changes. const alias = /** @type {const} */ ('packed') -const declarations = /** @type {const} */ ('declarations') +/** + * The whole check, as a file `tsc` reads for itself. + * + * `include` does the enumeration, so no shell walks the tree and no path is + * ever serialised: a space or a quote in a directory name is a JSON string + * here and a filename to `tsc`, with nothing in between to get it wrong. An + * empty match is `TS18003`, which names the pattern that found nothing — + * "checked nothing and passed" is the failure this job most needs to be + * legible about. + * + * `exclude` is emptied because the default excludes `node_modules`, which is + * the only place the artifact exists. `skipLibCheck` is stated rather than + * left at its default: it is the one option whose flip would stop `tsc` + * opening these declarations at all, and the job would still pass. + */ +const tsconfig = /** @type {const} */ ({ + include: [`node_modules/${alias}/**/*`], + exclude: [], + compilerOptions: { + module: 'nodenext', + target: 'esnext', + strict: true, + noEmit: true, + skipLibCheck: false, + }, +}) /** * One command per step, so a failure names what failed rather than arriving as @@ -35,17 +60,12 @@ const declarations = /** @type {const} */ ('declarations') */ const commands = pin => [ 'npm init -y > /dev/null', - `npm install "${alias}@file:$(ls *.tgz)"`, + // `echo` is the shell's own builtin expanding its own glob; `ls` would be + // a second process to learn what the shell already knew. + `npm install "${alias}@file:$(echo *.tgz)"`, `npm install "typescript@${pin}"`, - // `-print0` rather than a text list: the paths reach `tsc` as arguments, so - // a space or a quote in one survives without quoting or escaping. - `find node_modules/${alias} \\( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \\) -print0 > ${declarations}`, - // An empty list would type-check nothing and pass. `tsc` does exit non-zero - // on no arguments, but by printing usage, which says nothing about why. - `test -s ${declarations}`, - // skipLibCheck stays at its false default: it is what makes tsc open these - // declarations and report a reference the tarball does not carry. - `xargs -0 npx tsc --module nodenext --moduleResolution nodenext --target esnext --strict --noEmit --skipLibCheck false < ${declarations}`, + `echo '${JSON.stringify(tsconfig)}' > tsconfig.json`, + 'npx tsc', ] /** diff --git a/fjs/ci/package/proof.f.mjs b/fjs/ci/package/proof.f.mjs index c73ca73a2..8c54601fc 100644 --- a/fjs/ci/package/proof.f.mjs +++ b/fjs/ci/package/proof.f.mjs @@ -34,13 +34,11 @@ export const proof = { step => step.uses?.startsWith('actions/download-artifact@') === true) assertEq(download?.with?.name, packageArtifact) }, - // Each of these is what makes the job able to fail at all, and each has a - // silent failure mode rather than a loud one. + // The one option with a silent failure mode: `true` stops tsc opening the + // declarations at all, and the job still passes. Stated rather than left at + // its default so a change to it is a change to this file. canFail: () => { - // `true` stops the checking without saying so. - assert(scriptHas('--skipLibCheck false'), 'expected skipLibCheck left false') - // An empty list type-checks nothing and passes. - assert(scriptHas('test -s declarations'), 'expected a guard against an empty file list') + assert(scriptHas('"skipLibCheck":false'), 'expected skipLibCheck left false') }, // The compiler is whatever the package pins, carried through untouched. A // check that runs a compiler the package did not choose is a green result @@ -54,19 +52,18 @@ export const proof = { // instead would fail for them — or worse, silently check a dependency that // happens to share the name instead of the artifact just built. anyPackageName: () => { - assert(scriptHas('"packed@file:$(ls *.tgz)"'), 'expected the artifact installed under the fixed alias') - assert(scriptHas('find node_modules/packed'), 'expected declarations enumerated from that directory') - // Paths reach tsc as arguments, so a space or a quote in one needs no - // quoting or escaping to survive. - assert(scriptHas('-print0'), 'expected NUL-separated paths') - assert(scriptHas('xargs -0'), 'expected the paths passed as arguments') - // Every declaration form the package can ship, not just the two this - // repository happens to emit. - for (const ext of /** @type {const} */ (['*.d.ts', '*.d.mts', '*.d.cts'])) { - assert(scriptHas(`-name '${ext}'`), `expected ${ext} enumerated`) - } + assert(scriptHas('"packed@file:$(echo *.tgz)"'), 'expected the artifact installed under the fixed alias') assert( !scriptHas('node_modules/functionalscript'), 'the package check must not hard-code this repository\'s package name') }, + // `tsc` enumerates what it checks, from a config file it reads itself. No + // path passes through the shell, so a space or a quote in a directory name + // has nothing to survive; an empty match is TS18003 rather than a pass. + tscEnumerates: () => { + assert(scriptHas('"include":["node_modules/packed/**/*"]'), 'expected the artifact tree enumerated by tsc') + // The default excludes node_modules, which is the only place the + // artifact exists. + assert(scriptHas('"exclude":[]'), 'expected node_modules not excluded') + }, } From 20bb3b08bfc7739c5db2b9009f50c155a842d0b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:57:29 +0000 Subject: [PATCH 296/370] changelog: no drop-in replacement for the removed fsc grammars Review finding: "use that instead" overstated the migration. The deleted json.f.mjs exported json, digit, unicode, ws0, ws1 and wsNoNewLine0, while testlib.f.mjs exports only classic, deterministic and showAst; and deterministic returns [ws, value, ws] where the deleted json was the bare value rule, so following the old wording would change accepted input and leave five of six imports unresolved. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- changelog/unreleased/1768.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog/unreleased/1768.md b/changelog/unreleased/1768.md index f99cd0163..f34a536c6 100644 --- a/changelog/unreleased/1768.md +++ b/changelog/unreleased/1768.md @@ -1,4 +1,4 @@ -- **BREAKING CHANGES:** `fjs/fsc`: the unused `bnf.f.mjs` and `json.f.mjs` BNF - grammars are removed. They had no importer, no proof coverage, and the JSON - half duplicated `deterministic` in `fjs/bnf/testlib.f.mjs`; an importer of - `functionalscript/fjs/fsc/json.f.mjs` should use that instead. +- **BREAKING CHANGES:** `fjs/fsc`: the unused, unproven `bnf.f.mjs` and + `json.f.mjs` BNF grammars are removed, with no drop-in replacement. + `deterministic` in `fjs/bnf/testlib.f.mjs` is the surviving JSON grammar, but + it wraps the value rule in whitespace and replaces none of the other exports. From f0364d39342a4faae4d0e749b16bd518f6ff4aaf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:01:38 +0000 Subject: [PATCH 297/370] docs: move the package-check review answers out of the threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REVIEW.md: "where does this knowledge live once the pull request is merged? In the diff, in the design document, or in a todo/ issue. Only 'in the review thread' is wrong: it is the one place the answer will not survive." Nine findings on #1767 were answered only in threads. The answers move here. In the generator, next to the decision each explains: - Why tsc enumerates rather than a shell walk, with the three defects the find/test/xargs version had — including xargs' finite command buffer splitting a large package into separate programs, which was silent. Written as "do not go back", because the pipeline reads like the more explicit choice. - Why `**` rather than a list of declaration extensions, and what that costs: a package shipping sources and no declarations has a nonempty root set, so TS18003 cannot fire. - Why npm, npx and tsc are the tools that remain under AGENTS.md §6. - Why there is no guard against a second .tgz. In `fjs/ci/README.md`, the `package/` module joins the file list, stating the question a reader asks first: why the one job built without `toSteps`, and what a checkout would do to it. In a new `fjs/ci/todo/package-check-unsupported-package-shapes.md`, the three declined findings — typesVersions, refusing a project with no exact pin, and a package shipping sources — with what each would cost to build and what has to be true before it is worth building. They are one question, and it is the question `node26-typedef-gate-reaches-consumers.md` already asks about another job, so the two are linked. No generated output changes: `ci.yml` is byte-identical, which is the point of keeping the explanations in the source rather than in the emitted commands. 3479/3479, coverage 100%, tsc clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/README.md | 9 +++ fjs/ci/package/module.f.mjs | 27 +++++++ ...ackage-check-unsupported-package-shapes.md | 74 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 fjs/ci/todo/package-check-unsupported-package-shapes.md diff --git a/fjs/ci/README.md b/fjs/ci/README.md index ffbaec354..4d1968405 100644 --- a/fjs/ci/README.md +++ b/fjs/ci/README.md @@ -22,6 +22,15 @@ canonical Node job under `nix/generated/`. - `node/module.f.mjs` — Node.js job steps: platform smoke tests, canonical per-version jobs, coverage, package checks, and the Node flake declarations. `proof.f.mjs` — its property-based proofs. +- `package/module.f.mjs` — the `package-check` job: downloads the tarball the + Node job uploads, installs it under a fixed alias outside any checkout, and + type-checks every declaration it ships. It is the one job built without + `toSteps`, because that helper adds `actions/checkout` and the missing + checkout is the point — with the repository on the runner there would be a + `tsconfig.json` up the tree, a `node_modules` to resolve into, and sources + standing in for declarations the tarball omits, so the check would pass on + the repository rather than on the package. + `proof.f.mjs` — its property-based proofs. - `rust/module.f.mjs` — Rust toolchain setup and `cargo` build/test steps. - `deno/module.f.mjs` — Deno runtime steps. - `bun/module.f.mjs` — Bun runtime steps. diff --git a/fjs/ci/package/module.f.mjs b/fjs/ci/package/module.f.mjs index 673f07a77..043f4c597 100644 --- a/fjs/ci/package/module.f.mjs +++ b/fjs/ci/package/module.f.mjs @@ -30,6 +30,24 @@ const alias = /** @type {const} */ ('packed') * "checked nothing and passed" is the failure this job most needs to be * legible about. * + * An earlier revision walked the tree with `find`, guarded the result with + * `test -s`, and passed it through `xargs -0`. Do not go back: review found + * three defects in that mechanism, one of them silent. `find` omitted + * `.d.cts`; the paths needed escaping to survive the shell; and `xargs` fills + * a finite command buffer, so a package large enough to overflow it — about + * twenty times this one — would have been split across several `tsc` + * invocations, each a separate program, losing cross-file diagnostics and + * reporting another batch's globals as missing. One `include` has none of + * them, and root `AGENTS.md` §6 asks for the tool that parses what it checks + * rather than a pattern approximating one. + * + * `**` rather than a list of declaration extensions, for the same reason: + * a list is a thing that can be wrong, and that one already was. It does mean + * a package shipping `.ts` sources and no declarations has a nonempty root + * set, so `TS18003` would not fire — unreachable here, because root + * `package.json` `files` is an allowlist with no pattern matching a source + * file. Recorded in `../todo/package-check-unsupported-package-shapes.md`. + * * `exclude` is emptied because the default excludes `node_modules`, which is * the only place the artifact exists. `skipLibCheck` is stated rather than * left at its default: it is the one option whose flip would stop `tsc` @@ -56,12 +74,21 @@ const tsconfig = /** @type {const} */ ({ * registry — or a constant that drifted from `package.json` — decide the * verdict. * + * `npm`, `npx` and `tsc` are the only external tools left, and root + * `AGENTS.md` §6 is why there are no others: `tsc` is the established tool + * that parses what it checks, and `npm` is the subject — a job proving the + * package installs for a consumer cannot avoid the consumer's package manager. + * * @type {(pin: string) => readonly string[]} */ const commands = pin => [ 'npm init -y > /dev/null', // `echo` is the shell's own builtin expanding its own glob; `ls` would be // a second process to learn what the shell already knew. + // + // No guard against a second `.tgz`: the glob would expand to two names + // inside one `file:` spec and npm fails ENOENT naming both, which is + // louder than anything a count check would print. `npm install "${alias}@file:$(echo *.tgz)"`, `npm install "typescript@${pin}"`, `echo '${JSON.stringify(tsconfig)}' > tsconfig.json`, diff --git a/fjs/ci/todo/package-check-unsupported-package-shapes.md b/fjs/ci/todo/package-check-unsupported-package-shapes.md new file mode 100644 index 000000000..99ca1f89b --- /dev/null +++ b/fjs/ci/todo/package-check-unsupported-package-shapes.md @@ -0,0 +1,74 @@ +## package-check-unsupported-package-shapes. `package-check` assumes our package's shape + +**Priority:** P5 +**Status:** open + +### Problem + +[`../package/module.f.mjs`](../package/module.f.mjs) generates a job that +installs the packed tarball and type-checks it. It works for a package shaped +like this repository's, and three review findings on +[#1767](https://github.com/functionalscript/functionalscript/pull/1767) named +shapes it does not handle. Each was declined there; this file is where the +answers live now. + +All three are the same question — how far does `fjs ci` go for a project that is +not us? [`node26-typedef-gate-reaches-consumers.md`](./node26-typedef-gate-reaches-consumers.md) +asks it about a different job, and settling that one settles these. + +**1. Declarations reachable only through `typesVersions`.** The job checks every +declaration the tarball ships, which is a superset of what any entry point +reaches, so a `typesVersions` map changes nothing about *coverage*. What it +would change is whether the map itself is exercised: a package whose +`typesVersions` points at a path it does not ship gets a green check today. + +**2. A project with no exact `devDependencies.typescript`.** No job is +generated. The alternative is to refuse — fail `fjs ci` and say why — rather +than silently produce a workflow with one fewer job than the reader expects. +Declined because the generator's other jobs do not depend on a pin and a project +without one still wants them; the contract is written down in +[`../README.md`](../README.md) instead. + +**3. A package that ships `.ts` sources and no declarations.** `include` is +`**/*`, so TypeScript sources are a nonempty root set and the `TS18003` +empty-check never fires: `npx tsc` succeeds having checked no declaration. +Unreachable here — root `package.json` `files` is an allowlist +(`**/*.js`, `**/*.d.ts`, `**/*.mjs`, `**/*.d.mts`) with no pattern matching a +source file — and the fix has a real cost, so it is recorded rather than built. +See the comment on `tsconfig` in [`../package/module.f.mjs`](../package/module.f.mjs). + +### Proposal + +No design agreed, and deliberately so: the first two are only worth building +once a project outside this repository runs `fjs ci`, and none is known to. The +third is worth building the day this package ships a `.ts` source, which its +`files` field currently forbids. + +What each would cost, so the next person does not re-derive it: + +- **(1)** Read `typesVersions` from the installed `package.json` and add a + generated import per mapped entry. Needs the packed manifest parsed in a + generated step, which is new machinery for a case no consumer has. +- **(2)** A `Result` from `ci(setup)` and an error path through `fjs ci`. Cheap; + the question is whether refusing is right, not whether it is hard. +- **(3)** Either enumerate declaration extensions in `include` — the + hand-written list removed in + [5f90cda](https://github.com/functionalscript/functionalscript/commit/5f90cda), + which review had already caught omitting `.d.cts` — or count declarations in a + separate step, which needs a tool walking the tree and so needs approval under + [root `AGENTS.md` §6](../../../AGENTS.md#6-external-tools). Both undo a change + that fixed three defects. + +### Tasks + +- [ ] Establish whether any project outside this repository runs `fjs ci`. If + none does, close (1) and (2) as speculative generality. +- [ ] Build (3) the day root `package.json` `files` admits a `.ts` source, and + not before. + +### Related + +- [`../package/module.f.mjs`](../package/module.f.mjs) — the job. +- [`../README.md`](../README.md) — the `package.json` contract the job reads. +- [`node26-typedef-gate-reaches-consumers.md`](./node26-typedef-gate-reaches-consumers.md) + — the same "what is `fjs ci` for" question, about `node26`. From 7e3840c4ce901ca2441a05cad5a052569337a060 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:03:06 +0000 Subject: [PATCH 298/370] todo: three more one-answer fixes from the requested review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One compatibility policy for moved effects, decided by the test rather than a side: a re-export is a shim only when it keeps a dead coupling alive, and NodeOp is declared over Sandbox and Catch, so step 4's re-export passes while the console, test and all moves stay hard cutovers — both files now state the same policy. The all-argument-limit proposal no longer directs a browser interpreter to change: under the sequential plan none implements `all`. Step 7b names what re-landing RunOutcome is — a breaking change to runModuleMap's exported answer — and carries its obligations: an exitCodeOf helper, every importer migrated in the same PR, and the BREAKING CHANGES changelog entry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/all-argument-limit.md | 7 ++--- fjs/effects/todo/node-module-layering.md | 24 ++++++++++------- .../todo/share-browser-console-runner.md | 26 ++++++++++++++----- 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/fjs/effects/todo/all-argument-limit.md b/fjs/effects/todo/all-argument-limit.md index 7fef3a42c..a5fa78fe3 100644 --- a/fjs/effects/todo/all-argument-limit.md +++ b/fjs/effects/todo/all-argument-limit.md @@ -59,9 +59,10 @@ export type All = readonly['all', (effects: readonly Effect[] ``` Then `allOk(entries.map(one))` builds an array and hands it over, and no call in the path -grows with the suite. Every interpreter changes shape — `effects/node`, `effects/browser`, -the mock, and any fixture that supplies an `all` handler — which is what makes this its own -step rather than a fix inside another change. +grows with the suite. Every `all` handler changes shape — `effects/node`'s real and +virtual runners, the mock, and any fixture that supplies one — which is what makes this +its own step rather than a fix inside another change. Not a browser interpreter: under +the sequential plan the traversal performs no `all`, so no browser implements it. The variadic spelling is nicer at the two-or-three-effect call sites that motivated it (`both`, hand-written fan-outs in proofs), so a wrapper that keeps that shape over the diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index d0a5a169e..cfb298e05 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -214,15 +214,21 @@ Judgement calls worth deciding explicitly rather than by accident: concern per PR, update every importer in the same PR, and prefix the CHANGELOG entry with `**BREAKING CHANGES:**`. Do not leave re-export shims behind. - **The vocabulary move is the one exception, and for a reason that does not - generalize.** A re-export is a shim when it keeps a *dead* coupling alive — - which is the case for every move in the table above, where the whole goal is - that `fjs/text/sgr` stops naming `effects/node` at all. It is not the case - for `IoChannel` and its siblings: node's own operations are declared in - them, so `effects/node` re-exporting what it genuinely uses keeps one - vocabulary readable at one import rather than preserving a coupling anyone - wants gone. That is why that move was additive and needed no importer churn, - and why the moves below still need theirs. + **The exception is decided by a test, not by a list.** A re-export is a shim + when it keeps a *dead* coupling alive; it is legitimate where the + re-exporting module genuinely uses the names. The vocabulary move passed + that test — node's own operations are declared in `IoChannel` and its + siblings — and so does the `Sandbox`/`Catch` half of the sandbox row: + `NodeOp` is declared over both and the node interpreter implements both, so + `effects/node` re-exporting them keeps one operation set readable at one + import for node-side callers, while the modules the move exists for (the + shared traversal, a browser interpreter) import the new home directly. + Those two moves are therefore additive. The console, test and `all` moves + fail the test — their whole goal is that their consumers stop naming + `effects/node` at all — so they remain hard cutovers: update every importer + in the same PR, no re-export left behind. + [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) + step 4 states the same policy from its side. - **The obsolete Playwright adapter is already gone.** This task must preserve only the process-side `TestContext` fields that still have consumers. It must not use relocation as a reason to revive the Playwright engine, context, diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 2fc365a24..562d84258 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -310,7 +310,13 @@ and is reviewable without the next one. - [ ] **4. Common effects.** Move `sandbox` and `catch` out of `effects/node` into a shared module that `effects/node` re-exports unchanged, so - nothing has to move with them. + node-side callers keep one import. The re-export is legitimate here by + [node-module-layering](../../effects/todo/node-module-layering.md)'s own + test — a re-export is a shim only when it keeps a *dead* coupling + alive, and `NodeOp` is declared over `Sandbox` and `Catch`, so + `effects/node` genuinely uses what it re-exports. The modules the move + exists for — the shared traversal, the browser interpreter — import the + new home directly. **The list was settled by measurement, then shrank again by design.** The reverted #1759 interpreter implemented exactly `sandbox`, `catch` @@ -436,12 +442,18 @@ and is reviewable without the next one. stays there (step 6's note carries the same correction). What the reverted #1759 validated and this PR re-lands: the traversal threads a `RunOutcome` — folded totals plus each host's leaf records - in the walk's order (`fjs t` answers `void` and collects nothing); the - page's modules stay a *list* entered at a seam for already-collected - leaves, because labels may repeat and an export is enumerated exactly - once, under the page's own guard (catalog items 5, 6); the run starts - only after its promise is published (item 7); and both runner-failure - routes end in the `infrastructure-error` report (item 8). + in the walk's order (`fjs t` answers `void` and collects nothing). + **That is a breaking change to `runModuleMap`'s exported answer** — + today it is an exit code, `0 | 1` — and re-landing it carries the same + obligations it carried the first time: an `exitCodeOf` helper for + callers that want the code, every in-repo importer migrated in the same + PR, and a changelog entry with the `**BREAKING CHANGES:**` prefix + naming the return-shape migration. Also re-landed: the page's modules + stay a *list* entered at a seam for already-collected leaves, because + labels may repeat and an export is enumerated exactly once, under the + page's own guard (catalog items 5, 6); the run starts only after its + promise is published (item 7); and both runner-failure routes end in + the `infrastructure-error` report (item 8). **What stays the page's own, with the reason:** reading a *module's* exported tree. The shared walk guards a returned tree through `catch` From ec31c713ab3249bf8e2279063c533bae8471fbcb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:08:53 +0000 Subject: [PATCH 299/370] todo: Await gets its policy, and the all migration names its break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compatibility test classified Sandbox and Catch and left Await — which the same document moves — unclassified. It passes the same test: NodeOp declares it and both node runners implement it, so the whole sandbox row is additive. And the all-argument-limit task now says what either signature decision breaks: the operation change alone reaches every handler, dropping the variadic wrapper also reaches every fixed-arity caller, and both routes carry full importer migration and a BREAKING CHANGES entry — which is the argument for keeping the wrapper. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/all-argument-limit.md | 9 +++++++++ fjs/effects/todo/node-module-layering.md | 13 +++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/fjs/effects/todo/all-argument-limit.md b/fjs/effects/todo/all-argument-limit.md index a5fa78fe3..132471743 100644 --- a/fjs/effects/todo/all-argument-limit.md +++ b/fjs/effects/todo/all-argument-limit.md @@ -82,6 +82,15 @@ list-shaped operation is worth having in the same change. ### Tasks - [ ] Decide the list-shaped `All` signature and whether a variadic wrapper stays. + **Either way this is breaking, and the entry must say so.** Changing the + *operation* breaks every `all` handler however it is spelled at call + sites; dropping the wrapper additionally changes the published + `all`/`allOk` call shape, which reaches every fixed-arity caller + (`both`, hand-written fan-outs in proofs) and any external importer — + so the PR migrates every in-repo caller in the same change and carries + a `**BREAKING CHANGES:**` changelog entry naming what moved. Keeping + the wrapper narrows the break to the handlers, which is the argument + for keeping it. - [ ] Move every interpreter and fixture to it in one change, and every spread site in the table above with them. - [ ] Prove a fan-out above the current ceiling — the number itself is engine-specific, so diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index cfb298e05..44f2e90b0 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -218,12 +218,13 @@ Judgement calls worth deciding explicitly rather than by accident: when it keeps a *dead* coupling alive; it is legitimate where the re-exporting module genuinely uses the names. The vocabulary move passed that test — node's own operations are declared in `IoChannel` and its - siblings — and so does the `Sandbox`/`Catch` half of the sandbox row: - `NodeOp` is declared over both and the node interpreter implements both, so - `effects/node` re-exporting them keeps one operation set readable at one - import for node-side callers, while the modules the move exists for (the - shared traversal, a browser interpreter) import the new home directly. - Those two moves are therefore additive. The console, test and `all` moves + siblings — and so does the whole sandbox row, `Await` included: `NodeOp` is + declared over `Sandbox`, `Catch` and `Await`, and both node runners + implement all three, so `effects/node` re-exporting them keeps one + operation set readable at one import for node-side callers, while the + modules the move exists for (the shared traversal, a browser interpreter) + import the new home directly. The sandbox row's move is therefore + additive. The console, test and `all` moves fail the test — their whole goal is that their consumers stop naming `effects/node` at all — so they remain hard cutovers: update every importer in the same PR, no re-export left behind. From fe3ce8979eac6a5db02239018f0444a2020fcdd7 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 17:12:02 -0700 Subject: [PATCH 300/370] todo: move TypeScript to CI toolchain --- fjs/ci/todo/typescript-ci-tool.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 fjs/ci/todo/typescript-ci-tool.md diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md new file mode 100644 index 000000000..6135f52e3 --- /dev/null +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -0,0 +1,27 @@ +## Move TypeScript from npm devDependencies to the CI toolchain + +**Status:** open + +### Problem + +TypeScript is currently installed through the root `package.json` `devDependencies`, so every `npm ci` installs it even in CI jobs that do not run `tsc`. + +TypeScript is a development/CI tool rather than a runtime package dependency. CI already has infrastructure for pinning and provisioning tool versions, so TypeScript should be owned there instead of by npm package metadata. + +This task is intentionally limited to TypeScript. Keep `@types/node` in `devDependencies`. + +### Goal + +Provision a pinned TypeScript version through the CI/Nix tool environment and remove `typescript` from the root `package.json` `devDependencies`. + +Only jobs that actually run TypeScript need the tool. In particular, do not install TypeScript merely because a job runs `npm ci`. + +### Tasks + +- [ ] Add a pinned TypeScript version to the CI tool configuration. +- [ ] Make the canonical type-checking job provide that TypeScript version through its CI/Nix environment. +- [ ] Run `tsc` from `PATH` instead of relying on `npx tsc` / `node_modules/.bin/tsc`. +- [ ] Remove `typescript` from the root `package.json` `devDependencies` and update `package-lock.json`. +- [ ] Keep `@types/node` as a devDependency. +- [ ] Verify jobs that do not run `tsc` no longer install TypeScript unnecessarily. +- [ ] Verify `npm test`, `npm pack`, and other scripts that invoke `tsc` run only in environments where the CI/development toolchain provides it. From c3183d0146f6506912405be304199f92cea02921 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:12:32 +0000 Subject: [PATCH 301/370] todo: the sequential decision reaches the issues that link here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two linked issues still assumed the concurrent traversal. allreduce-combinator rewrote runModuleMap with a fan-out, which would undo the sequential decision; its consumers are now the sites that want fan-out — registration and dev file loading — and the traversal is excluded by name. report-before-running required concurrency to stay and asked for a concurrent-output format; it now inherits the sequential runner, where a start line is followed by its own result and the format question shrinks to the shape of the pair. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/allreduce-combinator.md | 17 ++++++++--------- .../todo/report-before-running.md | 13 ++++++++++--- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/fjs/effects/todo/allreduce-combinator.md b/fjs/effects/todo/allreduce-combinator.md index 91b11960b..a7f5aecfa 100644 --- a/fjs/effects/todo/allreduce-combinator.md +++ b/fjs/effects/todo/allreduce-combinator.md @@ -34,15 +34,14 @@ issue wrote it — would not compile. If `op` must be **commutative** — results may arrive in any order when the runner schedules sub-effects in parallel. -After adding `allReduce`, `runModuleMap` in `fjs/emergent_testing/module.f.mjs` simplifies to: - -```ts -return allReduce - (([k, v]: Entry) => runModule(reporter)(k, v)(zero)) - (mergeState) - (zero) - (modules) -``` +**`runModuleMap` is no longer a consumer.** An earlier draft of this issue +rewrote it with `allReduce`, and the sequential plan in +[share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) +decides the opposite: the proof traversal runs one leaf's whole chain before +the next, deliberately, and fanning its modules back out would undo that +decision. The combinator's consumers are the sites that *want* fan-out — the +framework-registration path and `dev/module.f.mjs`'s file loading — and it +must not be applied to the traversal. ### Naming diff --git a/fjs/emergent_testing/todo/report-before-running.md b/fjs/emergent_testing/todo/report-before-running.md index 1a0be4351..778738168 100644 --- a/fjs/emergent_testing/todo/report-before-running.md +++ b/fjs/emergent_testing/todo/report-before-running.md @@ -56,8 +56,12 @@ the shared core rather than twice. - A start event must not cost a `sandbox` call or a clock read of its own: the duration reported is still the sandboxed one. -- Concurrency stays. Naming a test before running it must not serialize the - suite to keep the output tidy. +- The runner's scheduling is not this issue's to change, in either direction. + When this was written that meant "concurrency stays"; the sequential plan in + [share-browser-console-runner](share-browser-console-runner.md) has since + made the traversal sequential, which this issue simply inherits — and + benefits from: starts and results no longer interleave, so a start line is + followed by its own result line, in both hosts. - Whatever is emitted has to be as useful to an automated consumer as to a reader — a start with no matching result is precisely the signal a crashed run leaves behind, and a controller should be able to read it. @@ -69,7 +73,10 @@ the shared core rather than twice. - [ ] Add the start event to the reporter and call it before the leaf is sandboxed. -- [ ] Decide the terminal format for concurrent output, and prove it. +- [ ] Decide the terminal format, and prove it. Under the sequential runner + output does not interleave, so the question is the shape of a + start-then-result pair rather than how to keep concurrent lines + legible. - [ ] Render a pending row in the browser page and settle it in place. - [ ] Prove that a run killed mid-test leaves the running test's name behind. From a194225ccdf4917c353524e450f007ea1ffb331c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:14:28 +0000 Subject: [PATCH 302/370] ci: reach declarations under dot-prefixed paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm's `**` walks into a dot-prefixed name and TypeScript's does not. `files` therefore publishes `.d/x.d.ts` and a lone `include` of `**/*` left it unchecked — silently, and even when it is the package's own `types` entry point. Reproduced with a package whose `types` is a broken `.hidden/b.d.ts`: installed, never loaded, job green. Two patterns naming a dot segment explicitly close the shapes anyone ships: `**/.*` for a dot-named file, `**/.*/**/*` for anything under a dot-named directory at any depth. Verified all four arrangements enumerated — visible, dot-named file, top-level dot directory, nested dot directory — and the reproduction now fails with TS2307 on the file it was skipping. The real package is unchanged at 396 declarations, exit 0. Unlike the other package-shape findings this one was reachable rather than structural: nothing here ships a dot-prefixed path today, but `files` would publish one. The fix is honest about its limit rather than claiming completeness. Two dot segments in a row still escape, because the inner `**` has to cross the second, and a pattern per arrangement of dots does not converge. Closing it properly means reading the installed tree's real names, which is a tool walking the tree and so the AGENTS.md §6 conversation. Recorded as issue 3 in fjs/ci/todo/package-check-unsupported-package-shapes.md, with that condition stated. 3479/3479, coverage 100%, tsc clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 2 +- fjs/ci/package/module.f.mjs | 16 +++++++++++- fjs/ci/package/proof.f.mjs | 8 +++++- ...ackage-check-unsupported-package-shapes.md | 26 +++++++++++++++---- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 296df9f00..89567b3d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -548,7 +548,7 @@ "run": "npm install \"typescript@=7.0.2\"" }, { - "run": "echo '{\"include\":[\"node_modules/packed/**/*\"],\"exclude\":[],\"compilerOptions\":{\"module\":\"nodenext\",\"target\":\"esnext\",\"strict\":true,\"noEmit\":true,\"skipLibCheck\":false}}' > tsconfig.json" + "run": "echo '{\"include\":[\"node_modules/packed/**/*\",\"node_modules/packed/**/.*\",\"node_modules/packed/**/.*/**/*\"],\"exclude\":[],\"compilerOptions\":{\"module\":\"nodenext\",\"target\":\"esnext\",\"strict\":true,\"noEmit\":true,\"skipLibCheck\":false}}' > tsconfig.json" }, { "run": "npx tsc" diff --git a/fjs/ci/package/module.f.mjs b/fjs/ci/package/module.f.mjs index 043f4c597..52d14bd05 100644 --- a/fjs/ci/package/module.f.mjs +++ b/fjs/ci/package/module.f.mjs @@ -48,13 +48,27 @@ const alias = /** @type {const} */ ('packed') * `package.json` `files` is an allowlist with no pattern matching a source * file. Recorded in `../todo/package-check-unsupported-package-shapes.md`. * + * The three patterns are one rule TypeScript and npm disagree about: npm's + * `**` walks into a dot-prefixed name and TypeScript's does not. So `files` + * publishes `.d/x.d.ts` and a lone `**` would leave it unchecked — silently, + * and even when it is the package's `types` entry point. The extra patterns + * name a dot segment explicitly, which does match: `**\/.*` for a dot-named + * file, `**\/.*\/**\/*` for anything under a dot-named directory at any + * depth. Two dot segments in a row (`.a/.b/x.d.ts`) still escape, because the + * inner `**` has to cross `.b` — see the todo. Enumerating the names instead + * would need a tool walking the tree, which root `AGENTS.md` §6 rules out. + * * `exclude` is emptied because the default excludes `node_modules`, which is * the only place the artifact exists. `skipLibCheck` is stated rather than * left at its default: it is the one option whose flip would stop `tsc` * opening these declarations at all, and the job would still pass. */ const tsconfig = /** @type {const} */ ({ - include: [`node_modules/${alias}/**/*`], + include: [ + `node_modules/${alias}/**/*`, + `node_modules/${alias}/**/.*`, + `node_modules/${alias}/**/.*/**/*`, + ], exclude: [], compilerOptions: { module: 'nodenext', diff --git a/fjs/ci/package/proof.f.mjs b/fjs/ci/package/proof.f.mjs index 8c54601fc..8dbb2edd0 100644 --- a/fjs/ci/package/proof.f.mjs +++ b/fjs/ci/package/proof.f.mjs @@ -61,7 +61,13 @@ export const proof = { // path passes through the shell, so a space or a quote in a directory name // has nothing to survive; an empty match is TS18003 rather than a pass. tscEnumerates: () => { - assert(scriptHas('"include":["node_modules/packed/**/*"]'), 'expected the artifact tree enumerated by tsc') + assert(scriptHas('"node_modules/packed/**/*"'), 'expected the artifact tree enumerated by tsc') + // npm's `**` walks into a dot-prefixed name and TypeScript's does not, + // so `files` publishes what a lone `**` would leave unchecked — even + // the package's own `types` entry point, and without saying so. Each + // pattern names a dot segment explicitly, which does match. + assert(scriptHas('"node_modules/packed/**/.*"'), 'expected dot-named files enumerated') + assert(scriptHas('"node_modules/packed/**/.*/**/*"'), 'expected declarations under a dot-named directory enumerated') // The default excludes node_modules, which is the only place the // artifact exists. assert(scriptHas('"exclude":[]'), 'expected node_modules not excluded') diff --git a/fjs/ci/todo/package-check-unsupported-package-shapes.md b/fjs/ci/todo/package-check-unsupported-package-shapes.md index 99ca1f89b..5ebd9e2fa 100644 --- a/fjs/ci/todo/package-check-unsupported-package-shapes.md +++ b/fjs/ci/todo/package-check-unsupported-package-shapes.md @@ -9,8 +9,8 @@ installs the packed tarball and type-checks it. It works for a package shaped like this repository's, and three review findings on [#1767](https://github.com/functionalscript/functionalscript/pull/1767) named -shapes it does not handle. Each was declined there; this file is where the -answers live now. +shapes it does not handle — one of them fixed as far as a glob can go, the rest +declined. This file is where the answers live now. All three are the same question — how far does `fjs ci` go for a project that is not us? [`node26-typedef-gate-reaches-consumers.md`](./node26-typedef-gate-reaches-consumers.md) @@ -29,7 +29,18 @@ Declined because the generator's other jobs do not depend on a pin and a project without one still wants them; the contract is written down in [`../README.md`](../README.md) instead. -**3. A package that ships `.ts` sources and no declarations.** `include` is +**3. A declaration under two consecutive dot-prefixed segments.** +`.a/.b/x.d.ts` is packed by npm and skipped by `tsc`. npm's `**` walks into a +dot-prefixed name and TypeScript's does not, so `include` names dot segments +explicitly — `**/.*` and `**/.*/**/*`, which cover a dot-named file and +anything under one dot-named directory at any depth. Two in a row still +escape: the inner `**` has to cross the second. Closing it properly means +enumerating the names, which needs a tool walking the tree and so needs +approval under [root `AGENTS.md` §6](../../../AGENTS.md#6-external-tools). +Nothing in this package ships a dot-prefixed path today; unlike the others +this one *is* reachable, because `files` would publish such a file. + +**4. A package that ships `.ts` sources and no declarations.** `include` is `**/*`, so TypeScript sources are a nonempty root set and the `TS18003` empty-check never fires: `npx tsc` succeeds having checked no declaration. Unreachable here — root `package.json` `files` is an allowlist @@ -51,7 +62,10 @@ What each would cost, so the next person does not re-derive it: generated step, which is new machinery for a case no consumer has. - **(2)** A `Result` from `ci(setup)` and an error path through `fjs ci`. Cheap; the question is whether refusing is right, not whether it is hard. -- **(3)** Either enumerate declaration extensions in `include` — the +- **(3)** A pattern per arrangement of dots does not converge — each new + pattern covers one more shape and there are unboundedly many. The complete + fix reads the installed tree's real names, so it is the §6 conversation. +- **(4)** Either enumerate declaration extensions in `include` — the hand-written list removed in [5f90cda](https://github.com/functionalscript/functionalscript/commit/5f90cda), which review had already caught omitting `.d.cts` — or count declarations in a @@ -63,8 +77,10 @@ What each would cost, so the next person does not re-derive it: - [ ] Establish whether any project outside this repository runs `fjs ci`. If none does, close (1) and (2) as speculative generality. -- [ ] Build (3) the day root `package.json` `files` admits a `.ts` source, and +- [ ] Build (4) the day root `package.json` `files` admits a `.ts` source, and not before. +- [ ] Close (3) if the §6 conversation permits a tool that reads the installed + tree; until then a dot-in-dot path is an unchecked declaration. ### Related From 2ee48468d6b950aaa31949707a3856e1b02a23c8 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 17:15:53 -0700 Subject: [PATCH 303/370] todo: document TypeScript developer setup --- fjs/ci/todo/typescript-ci-tool.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 6135f52e3..ff0e8b836 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -16,6 +16,8 @@ Provision a pinned TypeScript version through the CI/Nix tool environment and re Only jobs that actually run TypeScript need the tool. In particular, do not install TypeScript merely because a job runs `npm ci`. +Developers using the Nix environment should get the pinned TypeScript automatically. Developer documentation must also explain that non-Nix development requires TypeScript to be installed globally so `tsc` is available on `PATH`. + ### Tasks - [ ] Add a pinned TypeScript version to the CI tool configuration. @@ -23,5 +25,6 @@ Only jobs that actually run TypeScript need the tool. In particular, do not inst - [ ] Run `tsc` from `PATH` instead of relying on `npx tsc` / `node_modules/.bin/tsc`. - [ ] Remove `typescript` from the root `package.json` `devDependencies` and update `package-lock.json`. - [ ] Keep `@types/node` as a devDependency. +- [ ] Update developer documentation: Nix provides the pinned TypeScript; without Nix, install TypeScript globally and ensure `tsc` is on `PATH`. - [ ] Verify jobs that do not run `tsc` no longer install TypeScript unnecessarily. - [ ] Verify `npm test`, `npm pack`, and other scripts that invoke `tsc` run only in environments where the CI/development toolchain provides it. From 9384320fe9ecff0c4eb3daf8dc3785d9fc53ea12 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:15:59 +0000 Subject: [PATCH 304/370] todo: three more edges of the same decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit report-before-running's preliminary design still solved concurrent interleaving; under the inherited sequential runner the open line is completed in place, and the deferred-line and two-column alternatives go. Step 4 now says Await moves with sandbox and catch — on the layering argument, outside step 4's second-implementer scope — instead of reading as if it stays. And allReduce's body must call the list-shaped operation once all-argument-limit lands, so a combinator built for long lists is not another instance of the ceiling. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/allreduce-combinator.md | 6 ++++++ fjs/emergent_testing/todo/report-before-running.md | 10 +++++----- .../todo/share-browser-console-runner.md | 5 ++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/fjs/effects/todo/allreduce-combinator.md b/fjs/effects/todo/allreduce-combinator.md index a7f5aecfa..d883149ff 100644 --- a/fjs/effects/todo/allreduce-combinator.md +++ b/fjs/effects/todo/allreduce-combinator.md @@ -26,6 +26,12 @@ export const allReduce = rs => pure(rs.reduce((a, b) => op(b)(a), init))) ``` +**The body must call the list-shaped operation directly once +[all-argument-limit](./all-argument-limit.md) lands** — the spread above is +exactly the unbounded-spread shape that issue exists to remove, and a +combinator built for arbitrarily long lists must not become another instance +of the ceiling. Until then the spread inherits the documented limit. + Note the standalone `step`: `all(...)` returns a raw `Effect`, which is plain data with no methods, so `all(...).step(...)` — as an earlier draft of this issue wrote it — would not compile. If diff --git a/fjs/emergent_testing/todo/report-before-running.md b/fjs/emergent_testing/todo/report-before-running.md index 778738168..ef6eb088b 100644 --- a/fjs/emergent_testing/todo/report-before-running.md +++ b/fjs/emergent_testing/todo/report-before-running.md @@ -37,11 +37,11 @@ before the leaf is sandboxed, and let each host decide what to do with it: - **`fjs t`** prints the name, then completes the line with `ok`/`error` and the duration when the result lands — the standard runner shape, in the format it - already prints. Interleaving is the thing to get right: leaves - run concurrently, so a half-written line cannot be left open across another - test's output. Either the name and its outcome are one deferred line with the - name shown live elsewhere, or output is a two-column log that names the start - and closes it by identifier. + already prints. When this was written leaves ran concurrently and + interleaving was the thing to get right; under the sequential runner this + issue inherits (see the constraint below), nothing runs between a start and + its own result, so the open line is simply completed in place — no deferred + lines, no two-column log, no identifier to close by. - **The browser page** renders a row in a pending state and settles it in place, which is the same list it renders now with one more state per row. - **A result type** may not need to change at all: a start is an event, not a diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 562d84258..587e35ea2 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -327,7 +327,10 @@ and is reviewable without the next one. [node-module-layering](../../effects/todo/node-module-layering.md)'s question, which moves it to `effects/all` on the layering argument, with the Node runners and the registration path as its implementers. `await` never qualified: it belongs to that - registration path, which no browser runs. `import`, `now` and `fetch` + registration path, which no browser runs — though it *moves* with + `sandbox` and `catch`, to the same `effects/sandbox` home, on + node-module-layering's layering argument rather than on this step's + second-implementer one; that move is that issue's, not step 4's. `import`, `now` and `fetch` never qualified either: a page loads modules through its own importer and reads its own wall clock, in the impure shell where host values belong. Everything without a second implementer stays in `effects/node` From 703cd1e24605e0bdef6181ed479eb607ee5b6a81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:20:21 +0000 Subject: [PATCH 305/370] todo: two corrections and one honest application of my own test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batching's "third job" was a misattribution: Promise.all takes one iterable, so the old browser runner had no spread at any batch size — the ceiling belongs to the variadic operation alone. Item 4 is two jobs now, with the wrong claim recorded as wrong. The re-export test, applied honestly, admits All too: NodeOp unions it and both runners implement it, so its move is additive like the sandbox row's, and the hard cutovers are the moves whose goal is that consumers stop naming effects/node — console and test. And step 7b names the one observable ordering change that rides with the port: live progress becomes parent-before-children, the structural order the report and fjs t already use, to be proven rather than inherited silently. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/all-argument-limit.md | 11 ++++++----- .../todo/share-browser-console-runner.md | 15 +++++++++------ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/fjs/effects/todo/all-argument-limit.md b/fjs/effects/todo/all-argument-limit.md index 132471743..bab142d3c 100644 --- a/fjs/effects/todo/all-argument-limit.md +++ b/fjs/effects/todo/all-argument-limit.md @@ -39,11 +39,12 @@ close to either — the browser suite is 3,461 leaves across 138 modules, three magnitude under both — so this is a real ceiling rather than a live problem, and it is recorded rather than fixed for that reason. -The browser runner avoids it accidentally: it fans out in batches of 25 through -`Promise.all`, so it never spreads more than 25 arguments — a protection nobody asked for -or noticed, one of three unnamed jobs that constant turned out to do (see the pitfall -catalog in -[share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md)). +The browser runner is immune for a reason that has nothing to do with its batching: +`Promise.all(batch.map(…))` passes one iterable argument, so no spread exists there at any +batch size — the ceiling is the *variadic operation's*, not fan-out's in general. (An +earlier version of this paragraph credited `batchSize = 25` with staying under the limit; +that was a misattribution, corrected in the pitfall catalog in +[share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md).) The reverted functionalscript#1759 routed the page through the shared traversal and so briefly gave both runners the same ceiling; the sequential plan that replaced it removes the traversal's fan-outs entirely, which retires the `walkEntries` and `runModuleMap` rows diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 587e35ea2..aa9e7c6d6 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -208,12 +208,15 @@ the third is about method. can catch it. Sequential removes every traversal site; [all-argument-limit](../../effects/todo/all-argument-limit.md) keeps the rest. -4. **`batchSize = 25` was doing three unnamed jobs**: its `setTimeout` between - waves was the page's only macrotask boundary; awaiting each batch bounded - how far reporting lagged execution; and 25-at-a-time stayed under the - argument ceiling. Nobody chose it for any of them. The lesson is not that - the constant was right — it was indefensible — but that **before deleting - unmotivated code, enumerate what it does, not what it was for.** +4. **`batchSize = 25` was doing two unnamed jobs**: its `setTimeout` between + waves was the page's only macrotask boundary, and awaiting each batch + bounded how far reporting lagged execution. Nobody chose it for either. (A + third was claimed during review — staying under the argument ceiling — and + was a misattribution: `Promise.all(batch.map(…))` passes one iterable, so + the old runner had no spread at any batch size; the ceiling is item 3's, + the variadic operation's.) The lesson is not that the constant was right — + it was indefensible — but that **before deleting unmotivated code, + enumerate what it does, not what it was for.** **These survive into any implementation:** From 63da0e4d2d373fcc2da4b458d86d5fb0292a9ff3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:20:41 +0000 Subject: [PATCH 306/370] todo: All passes the test too, and 7b names its ordering change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-export test applied honestly admits All: NodeOp unions it and both runners implement it, so its move is additive like the sandbox row's; the hard cutovers are the moves whose goal is that consumers stop naming effects/node — console and test. Step 7b names the one observable ordering change that rides with the port: today's page announces children before their parent, and the port adopts the shared parent-first structural order for live progress too — to be proven rather than inherited silently. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/node-module-layering.md | 11 +++++++---- .../todo/share-browser-console-runner.md | 8 ++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index 44f2e90b0..1d8dd221c 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -224,10 +224,13 @@ Judgement calls worth deciding explicitly rather than by accident: operation set readable at one import for node-side callers, while the modules the move exists for (the shared traversal, a browser interpreter) import the new home directly. The sandbox row's move is therefore - additive. The console, test and `all` moves - fail the test — their whole goal is that their consumers stop naming - `effects/node` at all — so they remain hard cutovers: update every importer - in the same PR, no re-export left behind. + additive — and so is the `all` row's, by the same test applied honestly: + `NodeOp` unions `All` and both node runners implement it, so `effects/node` + re-exporting it is the same one-import convenience, not a dead coupling. + The console and test moves are the ones that fail the test — their whole + goal is that their consumers stop naming `effects/node` at all — so those + remain hard cutovers: update every importer in the same PR, no re-export + left behind. [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) step 4 states the same policy from its side. - **The obsolete Playwright adapter is already gone.** This task must preserve diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index aa9e7c6d6..843064958 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -461,6 +461,14 @@ and is reviewable without the next one. promise is published (item 7); and both runner-failure routes end in the `infrastructure-error` report (item 8). + **One observable ordering change rides with this port, deliberately.** + Today's page announces a returned tree's *children before their parent* + — the parent's `result` callback fires after `Promise.all(children)` — + while the shared traversal reports a parent before the children its + return value produced, which is the structural order the report and + `fjs t` already use. The port adopts the shared order for live progress + too; prove it rather than inheriting it silently. + **What stays the page's own, with the reason:** reading a *module's* exported tree. The shared walk guards a returned tree through `catch` (see [hostile proof values](hostile-proof-values.md)) but deliberately From a45429d8e401b201cdcfc719cc9a888538635fa7 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 17:21:23 -0700 Subject: [PATCH 307/370] todo: address TypeScript CI review comments --- fjs/ci/todo/typescript-ci-tool.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index ff0e8b836..2f2f052b6 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -1,5 +1,6 @@ ## Move TypeScript from npm devDependencies to the CI toolchain +**Priority:** P3 **Status:** open ### Problem @@ -12,19 +13,20 @@ This task is intentionally limited to TypeScript. Keep `@types/node` in `devDepe ### Goal -Provision a pinned TypeScript version through the CI/Nix tool environment and remove `typescript` from the root `package.json` `devDependencies`. +Provision a pinned TypeScript version through the CI tool environment and remove `typescript` from the root `package.json` `devDependencies`. -Only jobs that actually run TypeScript need the tool. In particular, do not install TypeScript merely because a job runs `npm ci`. +Only jobs that actually run TypeScript need the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. -Developers using the Nix environment should get the pinned TypeScript automatically. Developer documentation must also explain that non-Nix development requires TypeScript to be installed globally so `tsc` is available on `PATH`. +Local development must continue to support `tsc`, `npm test`, and `npm pack`: outside an environment that provides the compiler, developers install the pinned TypeScript globally so `tsc` is available on `PATH`. ### Tasks - [ ] Add a pinned TypeScript version to the CI tool configuration. -- [ ] Make the canonical type-checking job provide that TypeScript version through its CI/Nix environment. +- [ ] Provision that TypeScript version only in the canonical CI job that runs `tsc` (currently Node 26). - [ ] Run `tsc` from `PATH` instead of relying on `npx tsc` / `node_modules/.bin/tsc`. - [ ] Remove `typescript` from the root `package.json` `devDependencies` and update `package-lock.json`. - [ ] Keep `@types/node` as a devDependency. -- [ ] Update developer documentation: Nix provides the pinned TypeScript; without Nix, install TypeScript globally and ensure `tsc` is on `PATH`. -- [ ] Verify jobs that do not run `tsc` no longer install TypeScript unnecessarily. -- [ ] Verify `npm test`, `npm pack`, and other scripts that invoke `tsc` run only in environments where the CI/development toolchain provides it. +- [ ] Update `CONTRIBUTING.md` to list TypeScript as a developer tool, document installing the pinned version globally for local development, and replace `npx tsc` instructions with `tsc`. +- [ ] Update the Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks have the pinned TypeScript on `PATH` without relying on the root devDependency. +- [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily. +- [ ] Verify `tsc`, `npm test`, and `npm pack` work in every documented development environment. From 8f88689fcf33c050c1f542fcb90cfa7dec957305 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 17:24:34 -0700 Subject: [PATCH 308/370] todo: cover publish and tsc docs --- fjs/ci/todo/typescript-ci-tool.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 2f2f052b6..9df5cfa61 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -15,18 +15,19 @@ This task is intentionally limited to TypeScript. Keep `@types/node` in `devDepe Provision a pinned TypeScript version through the CI tool environment and remove `typescript` from the root `package.json` `devDependencies`. -Only jobs that actually run TypeScript need the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. +Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job and package publishing path do need it because `tsc` is invoked directly or through npm lifecycle scripts such as `prepack`. Local development must continue to support `tsc`, `npm test`, and `npm pack`: outside an environment that provides the compiler, developers install the pinned TypeScript globally so `tsc` is available on `PATH`. ### Tasks - [ ] Add a pinned TypeScript version to the CI tool configuration. -- [ ] Provision that TypeScript version only in the canonical CI job that runs `tsc` (currently Node 26). +- [ ] Provision that TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). +- [ ] Provision the pinned TypeScript in the npm publishing workflow so `prepack` uses the intended compiler during `npm publish`. - [ ] Run `tsc` from `PATH` instead of relying on `npx tsc` / `node_modules/.bin/tsc`. - [ ] Remove `typescript` from the root `package.json` `devDependencies` and update `package-lock.json`. - [ ] Keep `@types/node` as a devDependency. -- [ ] Update `CONTRIBUTING.md` to list TypeScript as a developer tool, document installing the pinned version globally for local development, and replace `npx tsc` instructions with `tsc`. +- [ ] Update repository-owned developer/check documentation, including `CONTRIBUTING.md`, `AGENTS.md`, `fjs/AGENTS.md`, and `fjs/ci/README.md`: list TypeScript as a developer tool where appropriate, document installing the pinned version globally for local development, and replace required `npx tsc` instructions with `tsc`. - [ ] Update the Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks have the pinned TypeScript on `PATH` without relying on the root devDependency. - [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily. -- [ ] Verify `tsc`, `npm test`, and `npm pack` work in every documented development environment. +- [ ] Verify `tsc`, `npm test`, `npm pack`, and the npm publish path work in every environment that is documented or responsible for those checks. From c5f74d20713a9a1a07f1f60125c1cfe8509d21b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:25:03 +0000 Subject: [PATCH 309/370] todo: two claims caught describing the reverted code as current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit all-argument-limit said the browser page reports the build-time throw as infrastructure-error; that described the reverted #1759, and the current page takes the Promise.all path and never builds the effect — today only fjs t is on the variadic path. The claim is now labeled as the reverted attempt's behavior. report-before-running still called interleaving "the real question" two paragraphs after inheriting the sequential runner; the remaining question is the event's shape, and the paragraph now says so. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/all-argument-limit.md | 8 +++++--- fjs/emergent_testing/todo/report-before-running.md | 10 +++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/fjs/effects/todo/all-argument-limit.md b/fjs/effects/todo/all-argument-limit.md index bab142d3c..a9ca0c50d 100644 --- a/fjs/effects/todo/all-argument-limit.md +++ b/fjs/effects/todo/all-argument-limit.md @@ -28,9 +28,11 @@ A spread is a call, and a call has an argument limit. Measured on node 22: | 100,000 | `RangeError: Maximum call stack size exceeded` | The throw is in **building** the effect, before any interpreter sees it, so no runner can -recover from it and no `catch` operation is in the path. `fjs t` panics; the browser page -reports one `infrastructure-error` because it guards the run's own failure, which is the -guard working as intended but not an answer. +recover from it and no `catch` operation is in the path. Today only `fjs t` is on this +path, and it panics. (The reverted functionalscript#1759 briefly put the browser page on +it too, where the page's run-failure guard reported one `infrastructure-error` — the guard +working as intended, but not an answer; the current page takes the `Promise.all` path +below and never builds the effect.) The ceiling applies **per fan-out**, and a run has two: one module with too many sibling leaves breaks the inner spread, and a run with too many *modules* breaks the outer one in diff --git a/fjs/emergent_testing/todo/report-before-running.md b/fjs/emergent_testing/todo/report-before-running.md index ef6eb088b..ea64c7e0e 100644 --- a/fjs/emergent_testing/todo/report-before-running.md +++ b/fjs/emergent_testing/todo/report-before-running.md @@ -48,9 +48,13 @@ before the leaf is sandboxed, and let each host decide what to do with it: result. Whether the reporter grows a sibling operation or its existing one gains a status is part of the design. -The reporter change is small; the interleaving question is the real one, and -it is the same question in both hosts, which is an argument for settling it in -the shared core rather than twice. +The reporter change is small, and the question that was the real one when +this was written — interleaving — is gone with the concurrency: under the +sequential runner nothing runs between a start and its own result. What +remains is the event's shape: whether the reporter grows a sibling operation +or its existing one gains a status, and what a start-then-result pair looks +like in each host. That is still the same question in both hosts, which is +still the argument for settling it in the shared core rather than twice. ### Constraints From 085365869f41797851df1c976cd7a3f1646a7706 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:32:09 +0000 Subject: [PATCH 310/370] todo: reconcile the re-export test and the tree walker with the sequential plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings against c5f74d20, both real: - node-module-layering: the caveat classified the console and test moves as failing the re-export test, but NodeOp unions Read, Write and Test, and NodeProgramOptions names WriteConsoles and TestContext — so the operation types pass the test the same way Sandbox and All do. The test applies per name, not per concern: type names the surviving effects/node declarations reference stay re-exported; the helpers those declarations never touch are the dead couplings and move as hard cutovers. - 65z-tf-test-tree-walker: the walkTests sketch hard-codes all(...) sibling fan-out, which the sequential plan removes from runModule while registerModule keeps it. Scheduling is now the instantiation's parameter, not the walker's; the spike waits for the sequential traversal to land; fixtures prove both schedules. share-browser-console-runner's related entry records the reconciliation so a later walker cannot undo step 7a. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/node-module-layering.md | 19 +++++-- .../todo/65z-tf-test-tree-walker.md | 49 ++++++++++++++----- .../todo/share-browser-console-runner.md | 5 +- 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index 1d8dd221c..defd6b493 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -227,10 +227,21 @@ Judgement calls worth deciding explicitly rather than by accident: additive — and so is the `all` row's, by the same test applied honestly: `NodeOp` unions `All` and both node runners implement it, so `effects/node` re-exporting it is the same one-import convenience, not a dead coupling. - The console and test moves are the ones that fail the test — their whole - goal is that their consumers stop naming `effects/node` at all — so those - remain hard cutovers: update every importer in the same PR, no re-export - left behind. + The console and test rows *split* under the same test rather than failing + it wholesale, because the test applies per name, not per concern: the + surviving `effects/node` declarations still reference the operation + types — `NodeOp` unions `Read`, `Write` and `Test`, and + `NodeProgramOptions` names `WriteConsoles` and `TestContext` — so those + names stay re-exported by the same argument as `Sandbox` and `All`. The + names those declarations never touch — the helpers (`log`, `error`, + `readLine`, `errorExit`, the `test` combinator) — are the dead couplings: + their consumers are exactly the ones the moves exist to decouple, so they + move as hard cutovers, every importer updated in the same PR, no re-export + left behind. Draw the exact split at move time by this test — grep what + the surviving `effects/node` declarations and runners reference — and note + that the decoupling each move exists for is enforced by its own step's + check (`fjs/text/sgr` no longer importing `effects/node`), which a type + re-export for node-side callers does not weaken. [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) step 4 states the same policy from its side. - **The obsolete Playwright adapter is already gone.** This task must preserve diff --git a/fjs/emergent_testing/todo/65z-tf-test-tree-walker.md b/fjs/emergent_testing/todo/65z-tf-test-tree-walker.md index 5c884559a..d843853a8 100644 --- a/fjs/emergent_testing/todo/65z-tf-test-tree-walker.md +++ b/fjs/emergent_testing/todo/65z-tf-test-tree-walker.md @@ -55,8 +55,9 @@ Both implementations: `registerModule` is a process-adapter path for the surviving external frameworks. It cannot always reuse `runModule`'s `Reporter` because of the external-framework constraint discussed in the module doc (lines 144-153). But the *traversal* (collect -leaves, recurse into function-return sub-trees, fan out with `all`) is shared and decouples -cleanly from the per-leaf action. +leaves, recurse into function-return sub-trees, combine the siblings — today both do that +with `all`, though the sequential plan changes `runModule`'s side; see the note under the +sketch) is shared and decouples cleanly from the per-leaf action. The removed Node-side Playwright integration is not a consumer of this design. A future Playwright Test adapter opens the shared browser application and consumes its report; it @@ -91,6 +92,22 @@ export const walkTests = (w: Walker) => { } ``` +**The sketch above predates the sequential plan and hard-codes the one thing +the two consumers no longer agree on.** The sequential plan in +[share-browser-console-runner](share-browser-console-runner.md) makes +`runModule`'s traversal sequential — one leaf's whole chain finishes before +the next starts — while `registerModule` keeps its `all` fan-out (its +recursion drives an external framework's own scheduling, and it is a site in +[all-argument-limit](../../effects/todo/all-argument-limit.md) either way). +So `all(...collectTests(...).map(...))` cannot live inside a shared walker: +scheduling is the *instantiation's* contract, not the walker's. A `walkTests` +that survives this takes the sibling combination as a parameter alongside +`merge` — a sequential fold for the run path, a fan-out for the registration +path — or it does not qualify. Any spike happens after the sequential +traversal lands, against the code as it then is; a walker that quietly +restores concurrency to `runModule`, or quietly serializes `registerModule`, +has broken a scheduling contract this repository has already paid to settle. + `runModule` instantiates `S = RunTotals`, threads `Sandbox`/`Reporter` effects in `onLeaf`, and returns the sub-tree value on success-without-`throws`. @@ -105,8 +122,9 @@ inside the page. Playwright itself remains outside that walker and only controls The exact `Walker` shape is open — it may be cleaner to split "should we recurse?" from "give me the sub-tree value" so the abstraction doesn't force a -boolean discriminator. The point is the recursion shape (collect → fan-out → -merge) lives in one place for the process-side implementations, while the browser runner +boolean discriminator. The point is the recursion shape (collect → visit each +sibling, under the instantiation's scheduling → merge) lives in one place for +the process-side implementations, while the browser runner shares the semantics rather than the obsolete Playwright registration path. ### Why this qualifies @@ -114,10 +132,11 @@ shares the semantics rather than the obsolete Playwright registration path. - **DRY at the right altitude.** `collectTests` already names the static walk; this names the dynamic one. Two process-side consumers exist today, and another process adapter, JSON reporter, or coverage instrumenter would otherwise copy it. -- **Separation of concerns.** The recursion structure (fan-out, merge, when - to stop) is one concern; the per-leaf action (sandbox+reporter vs. - framework registration) is another. Today they're entangled inside two - near-identical functions. +- **Separation of concerns.** The recursion structure (visit siblings, merge, + when to stop) is one concern; the per-leaf action (sandbox+reporter vs. + framework registration) is another — and the sibling *scheduling* belongs + to neither: it is each instantiation's contract, per the note under the + sketch. Today all three are entangled inside two near-identical functions. - **Documents the contract.** The "function-return sub-tree is walked the same way as the static export tree, with `throws` reset to `false` and a `null` marker appended to the path" rule is currently a comment in @@ -148,11 +167,16 @@ shares the semantics rather than the obsolete Playwright registration path. ### Tasks -- [ ] Spike a `walkTests` shape against the existing `runModule` and surviving - process-adapter `registerModule` implementations. +- [ ] Spike a `walkTests` shape against `runModule` and the surviving + process-adapter `registerModule` — after the sequential traversal from + [share-browser-console-runner](share-browser-console-runner.md) lands, + with the sibling combination as a parameter, per the note under the + sketch. - [ ] Keep Playwright out of `TestContext`, `registerModule`, and the process-side walker. - [ ] Define runner-independent fixtures for recursive return-value subtrees, `throws` - reset, path construction, and sibling fan-out. + reset, path construction, and sibling scheduling — proving the run path + sequential and the registration path fanned out, since the walker takes + the combination as a parameter. - [ ] Run those fixtures against both the process walker and the shared browser runner. - [ ] Keep the browser runner free of Node and Playwright imports. - [ ] Land the abstraction only when the existing process-side implementations become @@ -160,6 +184,9 @@ shares the semantics rather than the obsolete Playwright registration path. ### Related +- [Share the browser and console proof runners](share-browser-console-runner.md) + — the sequential plan that settled `runModule`'s scheduling, which this + issue's walker must take as a parameter rather than decide. - i183 — broader work on the `tf` framework; this is a structural cleanup that lands cleanly alongside it. - [i157](../../djs/todo/157-json-djs-shared-value-machine.md) — same flavour: two parallel diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 843064958..5d57035b9 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -792,7 +792,10 @@ are shared. - [Test-runner behavior](661-test-runner-behavior.md) — documented differences that must remain intentional after sharing the core. - [Test tree walker](65z-tf-test-tree-walker.md) — earlier work around recursive - proof-tree traversal. + proof-tree traversal. Its sketch predates the sequential plan and hard-coded + `all` sibling fan-out; that issue now requires the sibling combination to be + the instantiation's parameter (sequential for the run path, fan-out for + registration), so a later walker cannot undo step 7a's scheduling. - [Hostile thrown values and cross-realm promises](hostile-proof-values.md) — a behaviour the browser has and `fjs t` does not; decide it, do not inherit two answers. From 1879b33c46df3cc44f7efe0f39f51571554ee15a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:33:58 +0000 Subject: [PATCH 311/370] rtti: bound the container by its members too, not only its length The struct kind has no length, so `fits` is `() => true` for it and the bound settled nothing: an `or` of two closed structs differing by one trailing key stayed exponential, since the arm with too few declared keys was rejected by `undeclaredMembers` only after the walk had recursed into the shared member. Measured on such a schema at depth 20: 3.5s in `validate`, 4.7s in `parse`. Now under 1ms. `undeclaredMembers` moves into the structural pass beside `fits`, and the post-walk copy is dropped rather than duplicated -- one check, before the reads, for both kinds. So the rule is symmetric: the container is answered for having too much (an extra index, an undeclared key) and for having too little (an illegal absence) before any member is read. Acceptance is unchanged: 0 differences over the 1550-pair differential against main, and the two readers still agree on every error path. The struct half is pinned beside the tuple half in both proof suites. Reported by Codex on the pull request. --- fjs/rtti/parse/module.f.mjs | 12 +++----- fjs/rtti/parse/proof.f.mjs | 3 ++ fjs/rtti/validate/module.f.mjs | 54 ++++++++++++++++------------------ fjs/rtti/validate/proof.f.mjs | 6 ++++ 4 files changed, 39 insertions(+), 36 deletions(-) diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 126da6b5f..b6d90b7d8 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -340,13 +340,12 @@ const constContainerParse = if (!isContainer(value)) { return verror('unexpected value') } - // Presence, then the bound, then the reads — each decision - // made once and then used. See the comment on the same shape in - // `../validate/module.f.mjs`, including what reading `length` - // first assumes of the value. + // Presence, the bound, absence, then the reads. See the + // comment on the same shape in `../validate/module.f.mjs`, + // including what settling the shape first assumes of the value. const withPresence = rttiEntries.map(([k, t]) => /** @type {readonly[string, readonly[typeof t, boolean]]} */ ([k, [t, k in value]])) - if (!fits(value, declared.length)) { + if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { return verror('unexpected value') } // Absence before any read, for the reason `../validate`'s @@ -371,9 +370,6 @@ const constContainerParse = consDeclared, ) if (r[0] === 'error') { return r } - if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { - return verror('unexpected value') - } // The walk recorded the decisions it was given, so this asks // the pre-bound snapshot against the final state. if (!presenceUnchanged(rttiEntries, r[1].presence, value)) { diff --git a/fjs/rtti/parse/proof.f.mjs b/fjs/rtti/parse/proof.f.mjs index bb7d85f51..676060933 100644 --- a/fjs/rtti/parse/proof.f.mjs +++ b/fjs/rtti/parse/proof.f.mjs @@ -462,6 +462,9 @@ export const proof = { // and an absent required member answers before the members // ahead of it are read — see `../validate/proof.f.mjs` assertErrorPath(['1'])(parse([number, number])(['bad'])) + // an undeclared key likewise, which is the struct kind's + // half of the rule — see `../validate/proof.f.mjs` + assertErrorPath([])(parse({ a: number })({ a: 'bad', b: 1 })) }, // Nor is a key that is no position at all. nonIndexKeyRejected: () => diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index 9acf7d557..ec1cc6153 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -215,37 +215,38 @@ const constContainerValidate = if (!isContainer(value)) { return verror('unexpected value') } - // Decide each declared member's presence, bound the container, - // then read the members — in that order, and each decision made - // once and then used rather than re-derived. + // The container's **shape** is settled before any member is + // read: presence is recorded, the container is bounded, an + // illegal absence is rejected — and only then are the members + // read, from the flags already recorded. // - // The order is what makes an `or` of two arities linear instead - // of 2^depth, which is the shape a schema uses to say a trailing - // operand may be left out (`fjs/edag`'s chain nodes). Both steps - // earn their place, in opposite directions: the bound settles the - // arm whose value is too long, and the presence pass settles the - // one whose value is too short, by reaching its absent last - // member before any recursion. `parse` does the same, which is - // what keeps the two readers reporting the same error. + // That order is what makes an `or` of two arities linear + // instead of 2^depth, which is the shape a schema uses to say a + // trailing operand may be left out (`fjs/edag`'s chain nodes). + // The bound settles the arm whose value has too much — an extra + // index, an undeclared key — and the absence pass settles the + // arm whose value has too little. Neither alone suffices, and + // each was measured missing: without the bound, and without + // deciding absence early, a chain stays exponential in one + // direction or the other. `parse` does the same, which is what + // keeps the two readers reporting the same error. // - // Reading `length` before the members assumes reading it has no - // effect — true of every DJS value, and the assumption the - // readers are written under. What that gives up for a value built - // by arbitrary JavaScript is stated in "What the readers assume - // of a value" in `../README.md`. + // Reading `length` and enumerating the keys before the members + // assumes those reads have no effect — true of every DJS value, + // and the assumption the readers are written under. What that + // gives up for a value built by arbitrary JavaScript is stated + // in "What the readers assume of a value" in `../README.md`. const withPresence = rttiEntries.map(([k, v]) => /** @type {readonly[string, readonly[typeof v, boolean]]} */ ([k, [v, k in value]])) - if (!fits(value, declared.length)) { + if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { return verror('unexpected value') } - // Absence is answered before **any** member is read. Reaching - // an illegal absence through the reading walk would first - // recurse into the members that come before it, and those are - // the operands the longer arm shares — so an `or` of two - // arities would walk them once per arm at every level, which is - // the exponential all over again on a value the short arm has - // to reject. Measured on a chain of `['.', exp, index]` with a - // leaf no arm accepts: 2.5s at depth 16 without this pass. + // Reaching an illegal absence through the reading walk would + // first recurse into the members that come before it, and those + // are the operands the longer arm shares — so the two arms would + // walk them once each at every level, which is the exponential + // all over again. Measured on a chain of `['.', exp, index]` + // with a leaf no arm accepts: 2.5s at depth 16 without this. const a = eachEntry( withPresence, (_k, [v, present]) => present ? ok(undefined) : absentMember(v), @@ -265,9 +266,6 @@ const constContainerValidate = consPresence, ) if (r[0] === 'error') { return r } - if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { - return verror('unexpected value') - } // `value` is C (Unknown container), but Ts for T extends Tuple|Struct is not // structurally equivalent to C — TypeScript can't narrow element types through the loop. // The walk recorded the decisions it was given, so this asks diff --git a/fjs/rtti/validate/proof.f.mjs b/fjs/rtti/validate/proof.f.mjs index 2c7e4b5e7..d6f9cff0e 100644 --- a/fjs/rtti/validate/proof.f.mjs +++ b/fjs/rtti/validate/proof.f.mjs @@ -880,6 +880,12 @@ export const proof = { // this order exists to avoid const two = /** @type {const} */ ([number, number]) for (const read of [v, p]) { assertErrorPath(['1'])(read(two)(['bad'])) } + // and an undeclared member answers before the declared ones are + // read, which is the struct kind's half of the same rule — there + // `fits` is `() => true`, so the extra *key* is the only thing that + // can settle the arm whose value has too much + const one = { a: number } + for (const read of [v, p]) { assertErrorPath([])(read(one)({ a: 'bad', b: 1 })) } // and a value that fits is read as before for (const read of [v, p, d]) { assertOk(read(t)([42])) } }, From e68bbb3958e4b6be554e571686f61813697e92af Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:34:29 +0000 Subject: [PATCH 312/370] changelog: name the undeclared-key half of the bound --- changelog/unreleased/1766.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/changelog/unreleased/1766.md b/changelog/unreleased/1766.md index 68cd8f501..7836288da 100644 --- a/changelog/unreleased/1766.md +++ b/changelog/unreleased/1766.md @@ -1,5 +1,6 @@ - `rtti`: `validate` and `parse` answer a closed tuple's or struct's structure - before reading any member — presence recorded, length bound applied, then - illegal absence settled — so an `or` of two arities no longer walks shared - operands once per arm. Acceptance is unchanged; a container-level error now - wins over an absent member's, and both over an earlier member's. + before reading any member — presence recorded, the container bounded by + length and by undeclared keys, then illegal absence settled — so an `or` of + two arities no longer walks shared operands once per arm. Acceptance is + unchanged; a container-level error now wins over an absent member's, and + both over an earlier member's. From ebd418aaf4873cf5833f17623ae86c58561295be Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:36:27 +0000 Subject: [PATCH 313/370] todo: the start record is a complete line, not an open one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A proof can write to the terminal between its start and its result — purity is a convention the sandbox does not enforce, and Node warnings land on the same stream — so completing an open line in place would splice foreign output into it and attach the result to an unrelated line. The fjs t design now emits two self-contained records per leaf, the result naming the test again; the format task proves it with a proof that writes mid-test; the constraint notes what sequential does and does not buy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/report-before-running.md | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/fjs/emergent_testing/todo/report-before-running.md b/fjs/emergent_testing/todo/report-before-running.md index ea64c7e0e..364f65d7b 100644 --- a/fjs/emergent_testing/todo/report-before-running.md +++ b/fjs/emergent_testing/todo/report-before-running.md @@ -35,13 +35,18 @@ start event is adding a third event kind, not building the stream first. Add a `start` (or `begin`) event to the reporter, called with the file and path before the leaf is sandboxed, and let each host decide what to do with it: -- **`fjs t`** prints the name, then completes the line with `ok`/`error` and the - duration when the result lands — the standard runner shape, in the format it - already prints. When this was written leaves ran concurrently and - interleaving was the thing to get right; under the sequential runner this - issue inherits (see the constraint below), nothing runs between a start and - its own result, so the open line is simply completed in place — no deferred - lines, no two-column log, no identifier to close by. +- **`fjs t`** prints a complete, newline-terminated start record, then a + separate result line that names the test again. Not an open line completed + in place: no *other leaf* runs between a start and its own result under the + sequential runner, but the leaf itself does, and anything it writes to the + terminal — a proof that logs at runtime (purity is a convention the sandbox + does not enforce; see [hostile-proof-values](hostile-proof-values.md)), a + Node warning on stderr — would splice into an open line and attach the + later `ok`/`error` to unrelated output, corrupting the log for readers and + line-oriented consumers alike. Two self-contained lines per leaf survive + that; in the common case the result still directly follows its own start, + and the repeated name is what keeps the pair legible when something + intervenes. - **The browser page** renders a row in a pending state and settles it in place, which is the same list it renders now with one more state per row. - **A result type** may not need to change at all: a start is an event, not a @@ -64,8 +69,11 @@ still the argument for settling it in the shared core rather than twice. When this was written that meant "concurrency stays"; the sequential plan in [share-browser-console-runner](share-browser-console-runner.md) has since made the traversal sequential, which this issue simply inherits — and - benefits from: starts and results no longer interleave, so a start line is - followed by its own result line, in both hosts. + benefits from: one leaf's events no longer interleave with another's, so a + start is followed by its own result, in both hosts. What sequential does + *not* buy is an empty gap between them — the leaf itself runs there, and + its output can land on the same stream — which is why the terminal format + above emits two complete records rather than completing an open line. - Whatever is emitted has to be as useful to an automated consumer as to a reader — a start with no matching result is precisely the signal a crashed run leaves behind, and a controller should be able to read it. @@ -78,9 +86,11 @@ still the argument for settling it in the shared core rather than twice. - [ ] Add the start event to the reporter and call it before the leaf is sandboxed. - [ ] Decide the terminal format, and prove it. Under the sequential runner - output does not interleave, so the question is the shape of a + leaves do not interleave, so the question is the shape of a start-then-result pair rather than how to keep concurrent lines - legible. + legible — but a leaf's *own* output can still land between its start + and its result, so the proof includes a proof that writes to the + terminal mid-test and shows both records intact around it. - [ ] Render a pending row in the browser page and settle it in place. - [ ] Prove that a run killed mid-test leaves the running test's name behind. From 8654c2ac20c8ee45ec5c3a48430e4d10ecd4938e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:42:02 +0000 Subject: [PATCH 314/370] todo: the port is a behavior change for the page, named as such The 'one argument per PR' paragraph still said step 7b changes no behavior beyond calling the shared code, while 7b's own text names three page-behavior changes: 7a touches only module.f.mjs, so the browser acquires the sequential scheduling in 7b, alongside the live-progress ordering migration and the return-shape break. The paragraph now names all three; 7b's changelog obligation covers the scheduling and ordering migrations, proved in that PR, not only the return shape; the skeleton rule is restated as 'a port changes only the behavior its own argument requires, named and proved', with the old stronger wording recorded as superseded. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/share-browser-console-runner.md | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 5d57035b9..66526682d 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -106,12 +106,16 @@ both are properly issues rather than fixes inside a port: [Hostile thrown values and cross-realm promises](hostile-proof-values.md) and [Imports, promises and realms](imports-promises-realms.md). -The rule that follows: **land the shared skeleton with behaviour unchanged, then -take each new problem as its own change — in the skeleton where it belongs -there, so both runners get it, or in every part at once.** An improvement the -browser could have is an issue, not something to introduce inside a port. A -behaviour the port cannot preserve is a finding to record before it merges, not -a silent divergence to explain in review. +The rule that follows: **a port changes only the behaviour its own argument +requires, named and proved — everything else lands as its own change, in the +skeleton where it belongs there, so both runners get it, or in every part at +once.** An improvement the browser could have is an issue, not something to +introduce inside a port. A behaviour the port cannot preserve is a finding to +record before it merges, not a silent divergence to explain in review. (An +earlier version of this rule said "with behaviour unchanged" — right against +smuggled improvements, but stated too strongly once the plan itself became a +scheduling change the port necessarily brings to the page; the next paragraph +names what step 7b changes and why.) **Keep the change reviewable: one argument per PR.** The first attempt was 2646 insertions and 1408 deletions across 35 files in one PR — a move, a @@ -120,8 +124,15 @@ invention at once, which is why the scheduling argument could not be separated from the sharing argument. The sequence that keeps them separate is the one the plan below orders: the scheduling change first, alone, in the console runner where it is observable and provable without any port (step 7a); then -the port, which changes no behaviour beyond calling the shared code (step 7b); -the layout moves after. An earlier version of this paragraph said "shared +the port (step 7b) — which is itself a behaviour change for the *page*, three +times over, because 7a touches only `module.f.mjs` and the page does not run +that code until the port: the page's scheduling goes from 25-at-a-time +concurrent batches to sequential, its live progress goes from +children-before-parent to the structural order, and `runModuleMap`'s answer +changes shape. One argument per PR still holds — 7b's argument is the port, +and its behaviour changes are the browser's side of decisions 7a and this +plan already made and named, each carried in 7b's changelog and proofs rather +than discovered in review; the layout moves after. An earlier version of this paragraph said "shared semantics first, with `fjs t` unchanged in behaviour" — right about separation, wrong about order once the plan itself became a scheduling change: porting first would have moved the browser onto semantics about to change @@ -454,7 +465,12 @@ and is reviewable without the next one. obligations it carried the first time: an `exitCodeOf` helper for callers that want the code, every in-repo importer migrated in the same PR, and a changelog entry with the `**BREAKING CHANGES:**` prefix - naming the return-shape migration. Also re-landed: the page's modules + naming the return-shape migration — and, in the same entry, the two + page-behaviour changes this port carries: the page's scheduling moves + from 25-at-a-time concurrent batches to the sequential traversal (7a + changed only `module.f.mjs`; the browser acquires the scheduling + here), and live progress adopts the structural order (the paragraph + below). Both are proved in this PR, not just listed. Also re-landed: the page's modules stay a *list* entered at a seam for already-collected leaves, because labels may repeat and an export is enumerated exactly once, under the page's own guard (catalog items 5, 6); the run starts only after its From 0a8527253b41e6dd3d8c4f980dedd1d64aa7a86b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:42:34 +0000 Subject: [PATCH 315/370] rtti: ask the cheap structural question first `undeclaredMembers` enumerates every member the value and its prototypes carry; `fits` reads one `length`. Putting the enumeration first made an oversized array pay for a full scan before the length could reject it -- a million-element array against a one-element tuple went from under 1ms to 1.5s. The two answer alike on such a value, so the order is free to choose and the cheap one asks first. Reported by Codex on the pull request. --- fjs/rtti/parse/module.f.mjs | 3 ++- fjs/rtti/validate/module.f.mjs | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index b6d90b7d8..33c91cd9a 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -345,7 +345,8 @@ const constContainerParse = // including what settling the shape first assumes of the value. const withPresence = rttiEntries.map(([k, t]) => /** @type {readonly[string, readonly[typeof t, boolean]]} */ ([k, [t, k in value]])) - if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { + // `fits` first, for the reason `../validate`'s copy states. + if (!fits(value, declared.length) || undeclaredMembers(declared, value).length !== 0) { return verror('unexpected value') } // Absence before any read, for the reason `../validate`'s diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index ec1cc6153..27ca65830 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -238,7 +238,12 @@ const constContainerValidate = // in "What the readers assume of a value" in `../README.md`. const withPresence = rttiEntries.map(([k, v]) => /** @type {readonly[string, readonly[typeof v, boolean]]} */ ([k, [v, k in value]])) - if (undeclaredMembers(declared, value).length !== 0 || !fits(value, declared.length)) { + // `fits` first: it reads one `length`, where `undeclaredMembers` + // enumerates every member the value and its prototypes carry. On + // an oversized array the two answer alike, so the cheap one has + // to ask first — a million-element array against `[number]` is + // 1ms in this order and 1.5s in the other. + if (!fits(value, declared.length) || undeclaredMembers(declared, value).length !== 0) { return verror('unexpected value') } // Reaching an illegal absence through the reading walk would From 75f5aca1940c2c83bde871275623c5f4b125363b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:47:17 +0000 Subject: [PATCH 316/370] DESIGN, todo: separation forbids the combination, not a fixed order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An implementer could not follow both texts: DESIGN.md required a behavior-preserving port with the scheduling change afterwards, while the sequential plan lands the scheduling change first, for the recorded reason that porting first would move the browser onto semantics about to change under it. DESIGN.md's rule now states what it always argued — the new idea and the port cannot be combined — and names both orders: port first when the port reveals the idea, idea first when the idea is the premise and provable in the existing context. The plan's 'adds no scheduling of any kind' is replaced with the precise claim: the port invents no scheduling of its own — the sequential order arrives with the shared traversal (a named page-behaviour change), the report handler's macrotask yield is page code, and the interpreter has none. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- DESIGN.md | 20 ++++++++++++++----- .../todo/share-browser-console-runner.md | 9 ++++++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index c224dbc3a..2033feb26 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -173,11 +173,21 @@ the new context is often a decision made in the old one. Copy it first; if it turns out to be wrong, it is wrong in both places and worth an issue that says so. -**Keep the port separate from everything it inspires.** Land the sharing change -on its own, with behaviour unchanged. Anything new — a different scheduling -policy, a better measurement, an extra guard — is its own change afterwards. -Combined, they cannot be reviewed: an argument about the new idea becomes an -argument about the port. +**Keep the port separate from everything it inspires.** Anything new — a +different scheduling policy, a better measurement, an extra guard — is its own +change, never part of the port. Combined, they cannot be reviewed: an argument +about the new idea becomes an argument about the port. What the rule forbids is +the combination, not a fixed order. The common order is port first, behaviour +unchanged, because the port is usually what reveals the idea. When the idea is +the *premise* — decided before any port, and provable in the existing context +on its own — the same separation runs the other way: land the idea first, in +the context that can prove it, then the port, which then carries no idea of its +own beyond what the shared code already does. (An earlier version of this rule +said "with behaviour unchanged... afterwards", prescribing the order; the +sequential-runner plan in +[share-browser-console-runner](fjs/emergent_testing/todo/share-browser-console-runner.md) +is the case that showed the order is the consequence, not the rule — porting +first would have moved a context onto semantics about to change under it.) ### Exception to DRY: performance measurement diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 66526682d..c5b55e38e 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -189,7 +189,14 @@ Sequence it as two PRs: **first the sequential traversal in `module.f.mjs` alone** — console-observable, `fjs t` prints each line as its test finishes, the full suite run under it is what finds any concurrency-dependent proof, and the scheduling change is breaking and gets its own changelog entry — **then the -browser port**, which at that point adds no scheduling of any kind. +browser port**, which invents no scheduling of its own: the sequential order +arrives with the shared traversal the page now calls (a page-behaviour change, +named as such in step 7b and the reviewability paragraph above), the page's +`report` handler yields one macrotask as page code, and the browser +interpreter contains no scheduling at all. This order — the idea first, in the +context that can prove it, then a port that carries no idea of its own — is +the sequencing [DESIGN.md](../../../DESIGN.md) describes for a change that is +the plan's premise rather than the port's discovery. #### The pitfall catalog From 6db0651f48482c382c012b13b10e6a411dee52e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:50:25 +0000 Subject: [PATCH 317/370] DESIGN: the workflow's 'behaviour unchanged' is as of the port The numbered order of work still stated step 1 unconditionally while the port-separation rule below it admits the idea-first order. Step 1 now says the core is taken with its behaviour unchanged as it stands when the port begins: an idea landed first already lives in the core, the port copies it, and the port stays separate from the policy it acquires because the policy's argument was reviewed in its own change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- DESIGN.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/DESIGN.md b/DESIGN.md index 2033feb26..f0a75c50f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -144,7 +144,13 @@ supplies — rather than a special case. The order of work follows from that: 1. **Share the skeleton.** Take the existing implementation as the core, with - its behaviour unchanged. + its behaviour unchanged — unchanged *as it stands when the port begins*. + When an idea lands first in the existing context (the idea-first order + below), the core the port takes already carries it, and the port copies + that. Such a port does acquire the new policy, and stays separate from it + all the same: the policy was argued, landed and proved in its own change, + in the context that could prove it, so the port's argument is only the + port. 2. **Adjust the parts** the new context genuinely requires, or extend the skeleton so it can express what the new context needs. 3. **Document every difference that remains,** at the part where it is made. From 2aa1eadf45e29bfc7547ec0d138b0bd3fbe7cdc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:55:50 +0000 Subject: [PATCH 318/370] fsc: record in the README why there is no grammar here todo/README.md asks for a done issue's design decisions to land in the relevant README or JSDoc before its file is deleted. The rationale went into three sibling todos instead, and todos are transient - the coordinating plan is itself deleted when the epic completes - so the durable half of the record was missing. The README now says what was deleted, why it was not kept as an example (its FunctionalScript half separates statements by newline, which the language no longer does), and that the front end arrives by moving the existing one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- fjs/fsc/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/fjs/fsc/README.md b/fjs/fsc/README.md index bd4eba3be..4b708855c 100644 --- a/fjs/fsc/README.md +++ b/fjs/fsc/README.md @@ -1,5 +1,21 @@ # FunctionalScript Compiler +## There is no grammar here yet + +This package once held `bnf.f.mjs` and `json.f.mjs`, a FunctionalScript module +grammar over a complete JSON grammar, both written with `fjs/bnf` combinators. +They were deleted rather than kept: nothing imported them, no proof covered +them, the JSON half restated lexical rules +[`fjs/bnf/testlib.f.mjs`](../bnf/testlib.f.mjs)'s `deterministic` already +covers, and a concrete media grammar does not belong in the compiler at all. + +Their FunctionalScript half is also **stale by design**, which is why it was not +kept as an example: it separated statements by newline, and the language +requires `;`. Do not restore either file. The front end this package will hold +arrives by moving the existing one, per +[`todo/parser-serializer-restructure.md`](../../todo/parser-serializer-restructure.md); +git history has the deleted `id`/`alpha`/comment rules if they are ever wanted. + ## Source files and repository migration The FunctionalScript repository uses extensions to separate runtime source, From 421889226e3bcdfa88bf45948aa171bb107db73b Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 18:05:45 -0700 Subject: [PATCH 319/370] todo: preserve package-check TypeScript pin --- fjs/ci/todo/typescript-ci-tool.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 9df5cfa61..7ef3a2595 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -15,13 +15,14 @@ This task is intentionally limited to TypeScript. Keep `@types/node` in `devDepe Provision a pinned TypeScript version through the CI tool environment and remove `typescript` from the root `package.json` `devDependencies`. -Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job and package publishing path do need it because `tsc` is invoked directly or through npm lifecycle scripts such as `prepack`. +Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job, packed-package check, and package publishing path do need the pinned compiler because they invoke `tsc` directly, install it for declaration validation, or invoke it through npm lifecycle scripts such as `prepack`. Local development must continue to support `tsc`, `npm test`, and `npm pack`: outside an environment that provides the compiler, developers install the pinned TypeScript globally so `tsc` is available on `PATH`. ### Tasks - [ ] Add a pinned TypeScript version to the CI tool configuration. +- [ ] Make the packed-package check read its compiler pin from that CI configuration instead of `package.json` so removing `devDependencies.typescript` does not remove `package-check`; update the related proofs for the new pin source. - [ ] Provision that TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). - [ ] Provision the pinned TypeScript in the npm publishing workflow so `prepack` uses the intended compiler during `npm publish`. - [ ] Run `tsc` from `PATH` instead of relying on `npx tsc` / `node_modules/.bin/tsc`. @@ -30,4 +31,5 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Update repository-owned developer/check documentation, including `CONTRIBUTING.md`, `AGENTS.md`, `fjs/AGENTS.md`, and `fjs/ci/README.md`: list TypeScript as a developer tool where appropriate, document installing the pinned version globally for local development, and replace required `npx tsc` instructions with `tsc`. - [ ] Update the Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks have the pinned TypeScript on `PATH` without relying on the root devDependency. - [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily. +- [ ] Verify `package-check` remains generated and validates the packed declarations with the CI-configured compiler pin. - [ ] Verify `tsc`, `npm test`, `npm pack`, and the npm publish path work in every environment that is documented or responsible for those checks. From ae5da848a6bef7bb0853465d315fd7abb88c3e34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:06:28 +0000 Subject: [PATCH 320/370] todo: errorExit passes the re-export test; the code counts, not just types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exitStep stays in effects/node as the program's exit-code policy and calls errorExit (module.f.mjs:565), whose body calls error — so both names pass the per-name test and stay re-exported. Verified the rest of the helper list the same way: log, readLine and the test combinator have no surviving internal use and remain hard cutovers. The split instruction now says to grep the surviving declarations and function bodies, not declarations alone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/node-module-layering.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index defd6b493..0be8b0cff 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -233,12 +233,16 @@ Judgement calls worth deciding explicitly rather than by accident: types — `NodeOp` unions `Read`, `Write` and `Test`, and `NodeProgramOptions` names `WriteConsoles` and `TestContext` — so those names stay re-exported by the same argument as `Sandbox` and `All`. The - names those declarations never touch — the helpers (`log`, `error`, - `readLine`, `errorExit`, the `test` combinator) — are the dead couplings: - their consumers are exactly the ones the moves exist to decouple, so they - move as hard cutovers, every importer updated in the same PR, no re-export - left behind. Draw the exact split at move time by this test — grep what - the surviving `effects/node` declarations and runners reference — and note + test reaches the helpers one name at a time, and the surviving *code* + counts as much as the declarations: `exitStep` stays — it is the node + program's exit-code policy, consumed repo-wide — and it calls `errorExit`, + whose body calls `error`, so those two stay re-exported too. The names + nothing surviving touches — `log`, `readLine`, the `test` combinator — are + the dead couplings: their consumers are exactly the ones the moves exist + to decouple, so they move as hard cutovers, every importer updated in the + same PR, no re-export left behind. Draw the exact split at move time by + this test — grep what the surviving `effects/node` declarations *and + function bodies* reference — and note that the decoupling each move exists for is enforced by its own step's check (`fjs/text/sgr` no longer importing `effects/node`), which a type re-export for node-side callers does not weaken. From 49342061cf0ab348b5070fd656aa819125644648 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 18:11:01 -0700 Subject: [PATCH 321/370] todo: regenerate all dependency locks --- fjs/ci/todo/typescript-ci-tool.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 7ef3a2595..6ea96a77f 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -26,10 +26,10 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Provision that TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). - [ ] Provision the pinned TypeScript in the npm publishing workflow so `prepack` uses the intended compiler during `npm publish`. - [ ] Run `tsc` from `PATH` instead of relying on `npx tsc` / `node_modules/.bin/tsc`. -- [ ] Remove `typescript` from the root `package.json` `devDependencies` and update `package-lock.json`. +- [ ] Remove `typescript` from the root `package.json` `devDependencies`, then run `npm run update` so `package-lock.json`, `deno.lock`, `bun.lock`, and generated CI files are all regenerated consistently. - [ ] Keep `@types/node` as a devDependency. - [ ] Update repository-owned developer/check documentation, including `CONTRIBUTING.md`, `AGENTS.md`, `fjs/AGENTS.md`, and `fjs/ci/README.md`: list TypeScript as a developer tool where appropriate, document installing the pinned version globally for local development, and replace required `npx tsc` instructions with `tsc`. - [ ] Update the Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks have the pinned TypeScript on `PATH` without relying on the root devDependency. -- [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily. +- [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily and their frozen-lock installs still succeed. - [ ] Verify `package-check` remains generated and validates the packed declarations with the CI-configured compiler pin. - [ ] Verify `tsc`, `npm test`, `npm pack`, and the npm publish path work in every environment that is documented or responsible for those checks. From 5c7ec936aefb3a707aafd22b62f5578b698b7562 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:11:40 +0000 Subject: [PATCH 322/370] todo: 7a's sequential contract needs a proof that fails when work overlaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the full suite finds concurrency-dependent proofs but cannot defend the sequential contract: the suite is green under the concurrent traversal too, so a later edit restoring a fan-out would pass it. 7a now requires a mutation-sensitive proof — enter/exit ordering under a mock interpreter, or asserting the chain issues no all command — checked by restoring one fan-out and watching it fail, per catalog item 11. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/share-browser-console-runner.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index c5b55e38e..277ecbc83 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -451,7 +451,15 @@ and is reviewable without the next one. Breaking (scheduling semantics), so it carries its own changelog entry. Run the full suite under it *in this PR* — a proof that depends on a sibling running concurrently deadlocks here, where it is cheap to find, - not in the browser port. + not in the browser port. The suite run is *not* the proof of the + sequential contract, though: the suite is green under the concurrent + traversal too, so it would stay green if a later edit restored a + fan-out. The contract gets its own proof, one that fails when work + overlaps — leaves that record enter/exit order under a mock interpreter + and assert no interleaving between one leaf's start and its finish, or + an assertion that the traversal's chain issues no `all` command — and + per catalog item 11's discipline the proof is mutation-tested: restore + one fan-out, watch it fail, revert. **7b. The page runs the shared traversal** through the step-5 interpreter. `browser.mjs` stops discovering leaves, applying the throw From 2aa7513f16adedcd8921480fae15f6ff25096bc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:14:45 +0000 Subject: [PATCH 323/370] package: stop shipping generated private declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of the private-types design, and the last of it. A `!**/private.d.ts` negation in `package.json`'s `files` excludes the generated declarations; `prepack` is unchanged and the working tree is left alone, so a contributor who runs `npm pack` does not silently lose declarations a following `npx tsc` expects. The invariant the design asked for, measured rather than assumed: 679 packed files become 663, the 16 that disappear are all `private.d.ts`, and nothing else moves in either direction. The packed-declaration type-check merged in #1767 cannot guard this on its own. With `private.d.ts` shipped, every reference to it resolves and that job is green — so a dropped negation is invisible to it. The Node job therefore also asserts what was packed, from `npm pack --json`, npm's own account of the tarball. The two fail on opposite inputs: - negation dropped, private dependency present → contents assertion red, type-check green; - negation in place, private dependency present → type-check red (TS2307), contents assertion green. Both measured end to end before this landed. The second used `fjs/emergent_testing`, a module with no `private.ts` and not the package fixture, because a violation anywhere a hand-written import list would already look proves the check can fail but says nothing about whether the file set was enumerated. `node` for the assertion rather than a text search: the paths arrive as JSON and are compared as whole filenames, so nothing can mistake a path containing the name for one ending in it. AGENTS.md §6 asks for a tool that parses what it checks; here that is the runtime this repository is written in, already running in every job. `fjs/fsc/README.md` no longer tolerates a shipped `private.d.ts`, since none ships. The permanent half of the contract stays: `_` names still reach the declarations that do ship and are still not API. `fjs/todo/separate-private-types.md` is deleted, as it specified. Its thirteen inbound references across seven documents are retargeted to where the rules now live — root `AGENTS.md`, `fjs/AGENTS.md` §3.2, and `fjs/fsc/README.md` — rather than left dangling, and the measurements two CI todos cited from it are written into those todos. 3480/3480, coverage 100%, tsc clean, ci-update round-trips. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 5 +- .gitignore | 3 + fjs/AGENTS.md | 4 +- fjs/ci/node/module.f.mjs | 23 +- fjs/ci/proof.f.mjs | 28 +- fjs/ci/todo/ci-integration-tests.md | 10 +- fjs/ci/todo/f-mjs-package-support.md | 34 +- fjs/fsc/README.md | 20 +- fjs/todo/module-tag-restore.md | 13 +- fjs/todo/separate-private-types.md | 576 ------------------ .../jsdoc-typedef-doc-declaration-emit.md | 8 +- todo/migrate-typescript-to-mjs.md | 18 +- 12 files changed, 114 insertions(+), 628 deletions(-) delete mode 100644 fjs/todo/separate-private-types.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89567b3d2..897741d89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -508,7 +508,10 @@ "run": "npm run cov" }, { - "run": "npm pack" + "run": "npm pack --json > pack.json" + }, + { + "run": "node -e \"const f=JSON.parse(require('fs').readFileSync('pack.json','utf8'))[0].files.map(x=>x.path).filter(x=>x.endsWith('private.d.ts'));if(f.length!==0){console.error('packed private declarations:',f);process.exit(1)}\"" }, { "uses": "actions/upload-artifact@v7.0.1", diff --git a/.gitignore b/.gitignore index 656c3d22a..bc4587791 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,9 @@ _* **/*.d.mts **/*.d.ts +# `npm pack --json` output, read by the packed-contents assertion +/pack.json + test-results/ index.html diff --git a/fjs/AGENTS.md b/fjs/AGENTS.md index 1faa78133..75b9e8d01 100644 --- a/fjs/AGENTS.md +++ b/fjs/AGENTS.md @@ -150,8 +150,8 @@ reaches no reader, the tag buys nothing; `proof.*` is the clear case. Which reader differs by file kind, and the tag does not decide it. `module.f.mjs` and `types.ts` are public API surface. `private.ts` is not: it holds implementation-private types outside the public declaration closure, and -[`todo/separate-private-types.md`](./todo/separate-private-types.md) plans to -drop its generated declarations from the package altogether. Its prose is for +its generated declarations are excluded from the package entirely +([`fsc/README.md`](./fsc/README.md)). Its prose is for contributors reading the sources, so the tag belongs there — but a public documentation build must not be pointed at it. diff --git a/fjs/ci/node/module.f.mjs b/fjs/ci/node/module.f.mjs index 8783bd2bd..8e8ef2a36 100644 --- a/fjs/ci/node/module.f.mjs +++ b/fjs/ci/node/module.f.mjs @@ -72,6 +72,19 @@ const node24Steps = [ ] /** @type {readonly MetaStep[]} */ +const packListing = /** @type {const} */ ('pack.json') + +/** + * Fails if the package ships a generated `private.d.ts`. + * + * `node` rather than a text search over the listing: the paths arrive as JSON + * and are compared as whole filenames, so nothing here can mistake a path that + * merely contains the name for one that ends in it. Root `AGENTS.md` §6 asks + * for a tool that parses what it checks; here that tool is the runtime this + * repository is written in, already running in every job. + */ +const noPackedPrivateDeclarations = `node -e "const f=JSON.parse(require('fs').readFileSync('${packListing}','utf8'))[0].files.map(x=>x.path).filter(x=>x.endsWith('private.d.ts'));if(f.length!==0){console.error('packed private declarations:',f);process.exit(1)}"` + const node26Steps = [ ...nodeInstall(node.default), test({ run: 'npm run ci-update' }), @@ -82,7 +95,15 @@ const node26Steps = [ test({ run: "! grep -rnE '^(/\\*\\*.*@typedef|\\s\\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." }), test({ run: 'npx tsc' }), test({ run: 'npm run cov' }), - test({ run: 'npm pack' }), + // `--json` so the assertion below reads npm's own account of what it packed + // rather than re-deriving it. The tarball is still written; only `--dry-run` + // suppresses that. + test({ run: `npm pack --json > ${packListing}` }), + // The complement to the packed-declaration type-check, which cannot see + // this: with `private.d.ts` shipped, every reference to it resolves and the + // type-check is green. The two fail on opposite inputs, so neither stands + // in for the other. Measured both ways before this landed. + test({ run: noPackedPrivateDeclarations }), // Hands the tarball to a job that has no checkout, which is the only place // the package can be checked as a consumer sees it. `if-no-files-found` // must be `error`: the default warns and uploads nothing, so a consuming diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index ea035434f..241de192c 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -230,11 +230,37 @@ export const proof = { assert(job['runs-on'] !== undefined, 'expected runs-on') assert(job.steps.length > 0, 'expected steps') }, + // The packed-contents half of the private-declaration guard. The packed + // *declaration* type-check cannot see a dropped `files` negation: with + // `private.d.ts` shipped, every reference to it resolves and that job is + // green. Both were measured on the input that breaks each before this + // landed, and they are opposite inputs, so neither substitutes for the + // other. + noPackedPrivateDeclarations: () => { + const gha = run(false) + const job = gha.jobs[`node${major(node.default)}`] + assert(job !== undefined, 'expected the canonical Node job') + const packIndex = job.steps.findIndex(step => step.run?.startsWith('npm pack') === true) + const checkIndex = job.steps.findIndex( + step => step.run?.includes("endsWith('private.d.ts')") === true) + assert(checkIndex !== -1, 'expected the packed private-declaration check') + // It reads what packing produced, so it cannot run before packing. + assert(checkIndex > packIndex, 'expected the check to follow npm pack') + // A check that never fails is indistinguishable from one that passes. + assert( + job.steps[checkIndex]?.run?.includes('process.exit(1)') === true, + 'expected a non-zero exit on a packed private declaration') + // `--json` is what makes the listing machine-readable; without it the + // check reads prose and matches nothing, which is silently green. + assert( + job.steps[packIndex]?.run?.includes('--json') === true, + 'expected the pack listing emitted as JSON') + }, packageArtifact: () => { const gha = run(false) const job = gha.jobs[`node${major(node.default)}`] assert(job !== undefined, 'expected the canonical Node job') - const packIndex = job.steps.findIndex(step => step.run === 'npm pack') + const packIndex = job.steps.findIndex(step => step.run?.startsWith('npm pack') === true) const uploadIndex = job.steps.findIndex( step => step.uses === `actions/upload-artifact@${actions['actions/upload-artifact']}`) assert(packIndex !== -1, 'expected npm pack') diff --git a/fjs/ci/todo/ci-integration-tests.md b/fjs/ci/todo/ci-integration-tests.md index 9108fea91..382e291c0 100644 --- a/fjs/ci/todo/ci-integration-tests.md +++ b/fjs/ci/todo/ci-integration-tests.md @@ -36,10 +36,12 @@ Open questions: `fjs/ci/common/types.ts`, and cover the new field in the proof. Without it the two stages race and the consumer fails at `download-artifact`: red for the wrong reason, which is the one failure mode that trains - people to re-run a check instead of reading it. This blocks the stage - split below and the packed-declaration check in - [`../../todo/separate-private-types.md`](../../todo/separate-private-types.md) - alike, so it is owned here rather than by either consumer. + people to re-run a check instead of reading it. This blocked the stage + split below and the packed-declaration check alike, so it is owned here + rather than by either consumer. The `needs` field landed in + [#1762](https://github.com/functionalscript/functionalscript/pull/1762) + and its first consumer in + [#1767](https://github.com/functionalscript/functionalscript/pull/1767). - [ ] Implement scenario job generation: download artifact, install, run `main`. - [ ] Port existing demo/smoke-test steps (`fjs t`, `deno run … t`, `bunx … t`) to the scenario model. - [ ] Document the scenario authoring convention. diff --git a/fjs/ci/todo/f-mjs-package-support.md b/fjs/ci/todo/f-mjs-package-support.md index 6e6dce9a1..297d0cd61 100644 --- a/fjs/ci/todo/f-mjs-package-support.md +++ b/fjs/ci/todo/f-mjs-package-support.md @@ -166,12 +166,11 @@ exposes private types as `_`-prefixed names in `types.d.ts` and as generated `private.d.ts` files. Both are package-private by contract, not public API: clean-consumer tests must exercise documented public types and must not turn `_`-prefixed declaration artifacts into supported API merely because TypeScript -emitted them. Unshipping generated `private.d.ts` is the second stage of -[`fjs/todo/separate-private-types.md`](../../todo/separate-private-types.md), -by a `!**/private.d.ts` negation in `package.json`'s `files` — an exclusion at -pack time, with `prepack` unchanged and the working tree left alone. An earlier -draft of that design deleted the files instead; do not reintroduce a deletion -step. Once it lands, `private.d.ts` is no longer among the package-private +emitted them. Generated `private.d.ts` is no longer shipped: `package.json`'s +`files` carries a `!**/private.d.ts` negation — an exclusion at pack time, with +`prepack` unchanged and the working tree left alone. An earlier design draft +deleted the files instead; do not reintroduce a deletion step. So `private.d.ts` +is no longer among the package-private artifacts above — what remains is the `_`-prefixed names that still ship by design: `_` types emitted into `types.d.ts` and exported `_` constants emitted into `module.d.mts`. The leak-tolerance contract narrows to those, and stays @@ -247,8 +246,13 @@ emission, `npm pack`, and a clean consumer. in particular **not** this fixture. A hand-written import list would name the fixture, so a violation placed here fails under a fixed list too and proves nothing about enumeration. Measured end to end with - `fjs/emergent_testing` in - [`../../todo/separate-private-types.md`](../../todo/separate-private-types.md). + `fjs/emergent_testing`, which had no `private.ts`: given one, plus an + exported binding whose signature names it, the job exits 2 with + `TS2307` on the packed declaration. The same violation with the + `files` negation dropped is **green**, because the private + declaration then ships and the reference resolves — which is why the + packed-contents assertion in the Node job is a separate check and not + a restatement of this one. Scope: the fixture exercises the supported, fully erased `import type` form only. The forbidden inline `import { type X }` / `import * as` / side-effect forms are a documented one-time measurement @@ -330,9 +334,11 @@ emission, `npm pack`, and a clean consumer. typescript` lets the registry change the verdict with no repository change. The version is readable without a checkout: `npm pack` keeps `devDependencies` in the packed `package.json`. - The private-declaration assertion this job carries for - [`../../todo/separate-private-types.md`](../../todo/separate-private-types.md) - is a condition on it, specified there; the job itself belongs here. + The private-declaration assertion this job carries is a condition on it: + every packed declaration is type-checked from the installed artifact, so a + public declaration that came to depend on an unshipped private module is a + red build. Landed in + [#1767](https://github.com/functionalscript/functionalscript/pull/1767). - [x] Update `AGENTS.md` to the asymmetric `.f.ts` / `.f.mjs` migration policy. - [x] Decide, based on the fixture, whether the second TypeScript runtime-emission pass can ever be removed while authored `types.ts` files remain, or whether @@ -398,9 +404,9 @@ not, and the pipeline is simplified accordingly. two-pass `prepack`. - [`todo/migrate-typescript-to-mjs.md`](../../../todo/migrate-typescript-to-mjs.md) — repository-wide stage-1 implementation source migration. -- [`fjs/todo/separate-private-types.md`](../../todo/separate-private-types.md) - — private-type placement rules and the packaging stage that unships - generated private declarations. +- [`fjs/AGENTS.md`](../../AGENTS.md) §3.2 — private-type placement rules; + [`fjs/fsc/README.md`](../../fsc/README.md) — the `_` contract and why + generated private declarations are not packaged. - [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) — upstream JSDoc typedef stripping limitation; no longer a blocker here, since no authored `.mjs` declares a file-scope typedef to strip. diff --git a/fjs/fsc/README.md b/fjs/fsc/README.md index 4b708855c..8e2fd37aa 100644 --- a/fjs/fsc/README.md +++ b/fjs/fsc/README.md @@ -155,10 +155,8 @@ types — function-local in a proof. Private types and private runtime constants keep a leading `_`, even when linkage requires an export. The underscore is an API contract, not declaration-level visibility: generated `.d.ts` / `.d.mts` may still contain -`export type _Type = number` (and, until the packaging stage of -[`../todo/separate-private-types.md`](../todo/separate-private-types.md) lands, -a generated `private.d.ts` still ships), but names that begin with `_` are -private FunctionalScript implementation details. Consumers must not rely on +`export type _Type = number`, but names that begin with `_` are private +FunctionalScript implementation details. Consumers must not rely on those names directly, so renaming or removing a `_`-prefixed name is not a breaking change solely because TypeScript emitted it. The public contract still governs transitive effects: if a public type depends on `_Type`, changing @@ -199,11 +197,15 @@ module's public vocabulary may be published under an ordinary name even though its TypeScript alias was module-private, and a former export may become `_` when it only ever described an implementation detail. -Removing shipped private declaration artifacts (`private.d.ts`) from the -package is the second stage of -[`../todo/separate-private-types.md`](../todo/separate-private-types.md); the -`_` contract itself is permanent, since `_` helpers in `types.ts` and exported -`_` constants keep shipping in emitted declarations regardless. +No generated `private.d.ts` ships: `package.json`'s `files` excludes them with +a `!**/private.d.ts` negation, and CI asserts both halves of that — the tarball +carries none, and every declaration it does carry type-checks as an outside +consumer installs it, so a public declaration that came to depend on a private +module is a red build rather than a broken package. + +The `_` contract is permanent and independent of that. `_` helpers retained in +`types.ts` by the public declaration closure, and exported `_` constants, keep +shipping in emitted declarations; they are still not API. When the last authored implementation/proof `.ts` / `.f.ts` file is gone, authored `types.ts` files may remain. The TypeScript runtime-emission pass is diff --git a/fjs/todo/module-tag-restore.md b/fjs/todo/module-tag-restore.md index b9daa1010..7f5099715 100644 --- a/fjs/todo/module-tag-restore.md +++ b/fjs/todo/module-tag-restore.md @@ -28,10 +28,10 @@ same decision for the two file kinds. **`types.ts` yes, `private.ts` no.** `types.ts` is the public type-level API, so widening that glob to reach it belongs to the website issue. `private.ts` holds -implementation-private types outside the public declaration closure, and -[`separate-private-types.md`](./separate-private-types.md) plans to drop its -generated declarations from the package in Stage 2 — putting them on the public -API site would publish exactly what that design removes. Its prose is worth the +implementation-private types outside the public declaration closure, and its +generated declarations are excluded from the package +([`../fsc/README.md`](../fsc/README.md)) — putting them on the public API site +would publish exactly what packaging removes. Its prose is worth the tag for contributors reading the sources or running `deno doc` themselves; it is not website input. @@ -98,8 +98,7 @@ answer flips. The three bare-tag ones lost nothing either way. — the other half for group 1: its `**/module.f.mjs` glob would have to widen to `types.ts` before those descriptions reach a website reader. Not to `private.ts`. -- [`separate-private-types.md`](./separate-private-types.md) — why `private.ts` - is contributor-facing only, and why Stage 2 drops its declarations from the - package. +- [`../fsc/README.md`](../fsc/README.md) — why `private.ts` is + contributor-facing only, and why its declarations are not packaged. - [`../../todo/jsdoc-verification.md`](../../todo/jsdoc-verification.md) — how a rule like this might be checked at all, which is why it drifted twice unnoticed. diff --git a/fjs/todo/separate-private-types.md b/fjs/todo/separate-private-types.md deleted file mode 100644 index 29664f437..000000000 --- a/fjs/todo/separate-private-types.md +++ /dev/null @@ -1,576 +0,0 @@ -## Keep private types out of public declarations - -**Priority:** P2 -**Status:** open - -### Problem - -TypeScript declaration emit turns file-scope JSDoc `@typedef`s in authored -`.mjs` files into declaration aliases. Implementation-private `_` types therefore -leak into generated `.d.ts` / `.d.mts` files and add noise to the public surface. - -The requirement is a clean, self-contained public declaration/API boundary. -`private.ts` and subordinate modules such as `meta/module.f.mjs` are **tools** for -reaching that result, not required companion files. - -### Staging - -The work lands in two stages that are shippable independently: - -1. **Stage 1 — source restructuring.** Everything below except - [Declaration emission and packaging](#declaration-emission-and-packaging): - the file-scope typedef prohibition, the public declaration closure in - `types.ts`, optional `private.ts` and `meta/module.f.mjs`, the dependency - order, breaking migrations, and the matching policy documentation. -2. **Stage 2 — packaging cleanup.** The - [Declaration emission and packaging](#declaration-emission-and-packaging) - rules: exclude generated `private.d.ts` from the package and validate the - packed artifact semantically. - -Stage 1 is complete on its own. While Stage 2 has not landed, generated -`private.d.ts` files ship in the package. That is safe: `types.ts` must not -depend on `private.ts`, so no shipped public declaration semantically depends -on a `private.d.ts` — the shipped file is declaration noise only, the same -leak the existing `_` tolerance policy -([`../fsc/README.md`](../fsc/README.md)) already covers, consolidated into one -file per module. Deleting it in Stage 2 is therefore not a breaking change. - -Only the leak-tolerance **contract** survives Stage 1: consumers must not -depend on emitted `_` names or on a shipped `private.d.ts`, so removing them -later is not breaking. The **prescription** to create file-scope `_` typedefs -and the wait-for-`@internal`/`stripInternal` strategy contradict Stage 1 and -are rewritten as part of it. - -The `_` half of that contract is permanent, not a Stage 2 leftover: `_` -helpers retained in `types.ts` by the public declaration closure and `_` -constants exported from `meta/module.f.mjs` for linkage keep shipping in -`types.d.ts` / `module.d.mts` after Stage 2. Stage 2 retires only the -`private.d.ts` tolerance. - -A Stage 1 PR checks off the Stage 1 tasks and leaves this file in place; the -Stage 2 PR deletes it. - -### Rules (Stage 1) - -#### No file-scope typedefs in authored `.mjs` - -No authored `.mjs` anywhere in the repository may contain a **file-scope** JSDoc -`@typedef`, regardless of directory, basename, or whether the file is -FunctionalScript. This includes `module.f.mjs`, `proof.f.mjs`, host `.mjs` files, -descriptive companions such as `testlib.f.mjs`, and root/`todo/` files such as -`todo/proof.f.mjs`. - -Function-local typedefs remain allowed. This is especially useful for compile-time -proofs that need lexical or downstream runtime values: - -```js -const signatures = () => { - /** @typedef {Assert>, Effect<...>>>} _Step */ - /** @typedef {Assert>, Effect<...>>>} _CatchStep */ -} -``` - -Private type and runtime constant names continue to use a leading `_`. - -#### Public declaration closure - -`types.ts` describes the public declaration closure: - -- public types; -- private `_` helpers required transitively by shipped public declarations, - including declarations of exported runtime functions/values. - -For example, if an exported `find` declaration contains `_SortedArray`, then -`_SortedArray` is part of the public declaration closure and stays in `types.ts` -(or is inlined). Moving it to an unshipped private module would make the public -declaration incomplete. - -`types.ts` must not depend on `private.ts`. - -`private.ts` is optional. Use it only when separating implementation-private -file-scope types outside the public declaration closure makes the design cleaner. -Do not create it mechanically for every `_` name. - -#### Dependency order - -Within one module directory, preserve the dependency direction for the roles that -exist: - -```text -types.ts <- private.ts <- module.f.mjs <- proof.f.mjs <- module.mjs <- proof.mjs -``` - -The arrow points from dependency to dependent. This is a layering guide, not a -requirement that every file or edge exists. A subordinate module such as -`meta/module.f.mjs` is a separate module and is therefore described separately -below rather than appearing in this intra-directory diagram. - -Move verification downstream before moving implementation upstream. For example, -`fjs/effects/types.ts` currently imports implementation functions only to assert -`ReturnType` signatures. Those assertions verify `module.f.mjs`, so -move them into one or more proof functions in `proof.f.mjs`; keep the functions -in `module.f.mjs`. - -Analyze constrained and recursive cases individually rather than inventing broad -exceptions: - -- `fjs/media/revision`: `LockMap` / `LockSchema` can remain in `types.ts`; recursive - `lock` can remain in `module.f.mjs` when it requires the named `LockSchema` - annotation; move `Assert>` consistency checks into a proof function. -- `fjs/edag`: recursive RTTI such as `exp` can remain in `module.f.mjs` when its - annotation depends on public EDAG types; move file-scope consistency asserts - into proof functions. - -The goal is to preserve the dependency direction and simplify the public surface, -not to satisfy a mechanical file-placement rule. - -### Optional metaprogramming submodule - -When declarative runtime constants are shared between TypeScript and runtime code, -it can be useful to split them into a normal subordinate module, for example: - -```text -meta/ - module.f.mjs -``` - -`meta` here means **metaprogramming**: declarative definitions of types/schema-like -information that are useful at both compile time (TypeScript through `typeof`, -RTTI conversion, indexed access, etc.) and runtime. - -Typical examples are: - -- RTTI/schema constants; -- `as const`-style literal data; -- declarative lookup tables whose literal shape defines or constrains types. - -This is only a suggestion. Do not create `meta/` merely because a runtime value -appears in a type proof. Ordinary implementation functions stay in -`module.f.mjs`; recursively annotated metadata may also stay there when moving it -would reverse the dependency direction. - -The parent module may depend on `meta/module.f.mjs` like any other lower-level -module. The `meta/` module itself follows the same normal module conventions and, -if it grows additional files, its own intra-directory dependency order. - -A private constant exported from `meta/module.f.mjs` for sibling-module linkage -uses a leading `_`: - -```js -// meta/module.f.mjs -export const _framingKeywords = - /** @type {const} */ (['import', 'const', 'export', 'default', 'from']) -``` - -```ts -// private.ts or types.ts -import type { _framingKeywords } from './meta/module.f.mjs' -``` - -```js -// module.f.mjs -import { _framingKeywords } from './meta/module.f.mjs' -``` - -Exportability is linkage, not API status: `_` means consumers must not depend on -the name. Renaming/removing it is not breaking solely because it is exported. - -Because `meta/module.f.mjs` is just another `module.f.mjs`, existing tooling -already handles it: - -- emergent testing loads it as `*.f.mjs`; -- the existing Node `**/module.f.mjs` coverage filter includes it; -- the existing Deno `.*module\\.f\\.mjs` filter includes it. - -No special metadata filename or coverage rule is needed. - -### Breaking migrations - -Moving an existing public type from an authored `.mjs` declaration surface to -`types.ts` changes its public type import path. Moving an existing public runtime -constant into a subordinate module such as `meta/module.f.mjs` changes its runtime -import path. - -When such moves are chosen, treat them as intentional breaking changes: - -- update every repository importer; -- update the changelog; -- do **not** add compatibility typedefs, exports, or re-exports to preserve the - old entry point. - -Private `_` names are not public API merely because declaration emit or module -linkage exposes them. - -### Declaration emission and packaging - -This section is Stage 2. It may land after Stage 1 as a separate change. - -If `private.ts` is used, keep it in the normal TypeScript program so source users -are checked. Declaration emit may therefore create an intermediate -`private.d.ts`. - -Do not try to exclude `private.ts` from *checking*. Exclude the generated -`private.d.ts` from *packing* instead, by a negation in `package.json`'s `files`: - -```json -"files": ["**/*.js", "**/*.d.ts", "**/*.mjs", "**/*.d.mts", "!**/private.d.ts"] -``` - -Measured with `npm pack --dry-run --json`: the packed file count drops by -exactly 16, and the 16 that disappear are exactly the 16 emitted -`private.d.ts` — nothing else moves. The totals were 677 → 661 when last -re-measured; they drift as modules are added, so the invariant is the claim, -not the absolute figures. - -Prefer this to a deletion step in `prepack`. It needs no script, no directory -walk, and no proof for a path predicate; `prepack` keeps doing exactly what it -does now (emit declarations, then re-check with them present); and it leaves the -working tree alone, so a contributor who runs `npm pack` does not silently lose -the declarations a following `npx tsc` expects. It also states the intent where -the rest of the package contents are declared, rather than in a build step that -has to be read to be discovered. - -Do **not** rewrite/post-process emitted declaration text. TypeScript may retain a -source comment such as: - -```js -/** @import { _Private } from './private.ts' */ -``` - -inside an emitted declaration. In `.d.ts` / `.d.mts` this is only a comment, not -a TypeScript module dependency, so it may remain after the private declaration is -removed. - -Package validation must check semantic dependencies, not raw text: - -- no authored/generated private type artifact that is intended to be unshipped is - present in the tarball; -- no packed declaration semantically depends on an unshipped private type module; -- every declaration in the tarball, installed as a clean TypeScript dependency, - type-checks successfully. - -#### The check has to run in CI, on the packed artifact, without the repository - -Excluding `private.d.ts` from the package is invisible to every check the -repository has. `npx tsc` reads the *source* `private.ts`, so it stays green -whatever the tarball omits; `node26` runs `npm pack` but nothing installs or -type-checks the result, and the `npm install -g functionalscript@` -steps install the *published* CLI, not the artifact just built. Stage 2 would -therefore ship a claim that nothing could falsify — the same "a sweep, not a -check" gap the Stage 1 grep guard closes. Only a consumer that reads the packed -declarations can catch a declaration left pointing at a file the package no -longer carries. - -The shape that makes it a real check: - -1. a job that packs (`npm pack`) and uploads the tarball as a CI artifact; -2. a **second job with no repository checkout**, ordered after the first by an - explicit `needs`, that downloads that artifact, installs it — the tarball - plus the exact `typescript` version read from the tarball's own - `package.json` — and type-checks **every declaration the package ships**. - -The missing checkout is the point, and it is stronger than merely working in a -directory outside the repository: with no repository on the runner, there is no -`tsconfig.json` up the tree to inherit, no `node_modules` to resolve into, and -no source file that could stand in for a declaration the tarball omits. The -check can only see what a real consumer sees. - -Four details decide whether that job can fail at all — enumerate every packed -declaration rather than trusting a hand-written import list; leave -`skipLibCheck` at its `false` default; install the tarball as a real dependency; -and pin the compiler. They are conditions on a job this design does not own, so -they are recorded as tasks in -[`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) -with the reasoning for each. The first is the one this design turns on, and the -measurement below is why. - -Because a red required check blocks the merge queue, a reintroduced dependency -becomes the author's problem at the moment it is introduced, which is the whole -point of preferring a check to a sweep. - -Measured on the tree at the time of writing, with the tarball installed into a -scratch consumer and the 16 `private.d.ts` removed from it: - -- every remaining declaration type-checks with `skipLibCheck: false` — exit - `0` (378 of them when last re-measured), so the exclusion is safe today: the - `private.ts` mentions that survive emit are JSDoc `@import` comments, which - are inert; -- appending a real `import type { … } from './private.js'` to one packed - declaration turns that exit `2` with `TS2307`, so the check is falsifiable; -- and the gap the first of those describes is not hypothetical: with that - injection placed in `fjs/emergent_testing` — a module with no `private.ts` - today, standing in for a future one — a consumer importing all 16 of today's - private-carrying surfaces still exits `0`, while the exhaustive form exits - `2`. A fixed import list would have shipped a check that cannot see the case - it exists to catch. - -Those three inject the failure into an already-packed declaration, which shows -the check can fail but not that this repository's own workflow could *produce* -the artifact that fails it. It can, and the whole design was then run end to -end against it. The organic control is a source-level violation of the -public-declaration-closure rule — exporting a binding whose signature names a -private type, here `export const divide` in `fjs/types/bigfloat/module.f.mjs`, -typed `_BigFloatWithRemainder`: - -1. ordinary `prepack` emits a **real** `import type { _BigFloatWithRemainder } - from './private.ts'` into `module.f.d.mts` — not the inlined structural type, - and the specifier keeps its `.ts` extension, as declaration emit does for - `types.ts`; -2. `npm pack` with the `files` negation ships **0** `private.d.ts`; -3. installing that tarball and type-checking every packed declaration exits `2` - with `TS2307` naming that line; -4. and throughout, **every in-repo gate stays green** — `npx tsc` exits `0` and - `npm pack` succeeds with the violation in place. That is the claim at the - top of this section, that the exclusion is invisible to every check the - repository has, demonstrated rather than argued: the artifact is already - broken while nothing in the repository can say so. - -Two things follow. The check's real target is a closure-rule violation reaching -an exported signature — today no private type does, because every binding -annotated with one is module-private, which is why the tree measures clean. And -the control to write into the fixture is this source-level one, not an edit to -the packed output: it exercises emit, packing and consumption together, so it -also fails if a future TypeScript starts inlining the reference and the design's -premise quietly stops holding. - -The job is added through the CI generator (`fjs/ci/**`, composed in -`fjs/ci/module.f.mjs`), never by editing `.github/workflows/ci.yml`, which -`npm run ci-update` regenerates. - -This fixture is already scoped in -[`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md), -where the clean packed-consumer validation was performed **manually** in -[#1520](https://github.com/functionalscript/functionalscript/pull/1520) and the -committed CI fixture is the remaining work. Stage 2 completes that fixture and -adds the private-declaration assertion to it rather than standing up a second -package-validation path. - -### Repository policy - -When Stage 1 is implemented: - -- update root `AGENTS.md` with the repository-wide rule that authored `.mjs` files - may not contain file-scope JSDoc `@typedef`; -- update `fjs/AGENTS.md` with the public-declaration-closure rule, optional - `private.ts`, optional subordinate metaprogramming modules such as - `meta/module.f.mjs`, and the dependency-order guidance; -- rewrite the "Private JSDoc typedefs" section of `fjs/fsc/README.md`: authors - no longer create file-scope `_` typedefs; keep the leak-tolerance contract - for emitted `_` names and shipped `private.d.ts` until Stage 2; -- **done** — `jsdoc-typedef-strip-internal` was deleted with Stage 1: this - design supersedes waiting for `@internal`/`stripInternal`, so the repository - does not keep two conflicting private-type strategies; -- sweep the remaining Markdown documents repo-wide — `todo/` issues, plans, - and READMEs — for text that prescribes adding a file-scope JSDoc `@typedef` - to an authored `.mjs` or defers private types to `@internal`/`stripInternal`, - and retarget each to the Stage 1 forms: `types.ts`, optional `private.ts`, - function-local typedefs. The sweep is defined by the search, not by a list; - instances known at the time of writing are - `todo/migrate-typescript-to-mjs.md` ("Preserve private type intent with `_`" - and the typedef-visibility migration task), - `fjs/ci/todo/f-mjs-package-support.md` (its declaration-emission narrative - and its `_`-typedef fixture task), and - `fjs/effects/memory/todo/sync-interpreter-owner.md` (its proposed - `MemoryState` file-scope typedef belongs in `types.ts`). - -When Stage 2 is implemented: - -- narrow the `fjs/fsc/README.md` leak tolerance to what still ships by design: - drop the tolerance for shipped `private.d.ts`, which no longer exists, and - keep the permanent `_` contract — `_` names emitted into `types.d.ts` / - `module.d.mts` are not API, and renaming or removing one is not by itself a - breaking change. - -Authored TypeScript type modules (`types.ts`, and `private.ts` when present) remain -type-only and use named `import type { ... }` imports. - -### Tasks - -#### Stage 1 — source restructuring - -- [x] Document the repository-wide prohibition on file-scope JSDoc `@typedef` in - authored `.mjs`; allow function-local typedefs. -- [x] Migrate existing violations, including authored `.mjs` outside `fjs/` such - as `todo/proof.f.mjs`. -- [x] Keep `types.ts` as the public declaration closure; retain/in-line private - helpers required by public declarations. -- [x] Use `private.ts` only where separating implementation-private file-scope - types improves the design. -- [x] Preserve the intra-directory dependency direction shown above; move - verification downstream when that is cleaner. -- [x] Move the `fjs/effects/types.ts` implementation-signature asserts into proof - functions in `fjs/effects/proof.f.mjs`. -- [x] Review recursive cases individually, including `fjs/media/revision` and - `fjs/edag`; keep recursive RTTI in `module.f.mjs` when required by layering - and move consistency asserts into proof functions. -- [x] Where useful, split declarative compile-time/runtime constants into a normal - subordinate module such as `meta/module.f.mjs`; do not require it. The - migration warranted none: every recursive metaprogramming constant - (`fjs/edag`, `fjs/media/json/schema`) reads best staying in its - `module.f.mjs`; the option stays documented in `fjs/AGENTS.md` §3.2. -- [x] Preserve leading `_` for private types and private runtime constants. -- [x] Treat chosen public import-path moves as breaking changes with no - compatibility re-exports. -- [x] Add fixtures/examples covering: public-declaration helpers, optional - `private.ts`, function-local proof typedefs, recursive RTTI kept in - `module.f.mjs`, optional `meta/module.f.mjs`, and authored `.mjs` outside - `fjs/`. Live modules serve as the examples, cited from `fjs/AGENTS.md` - §3.2: `fjs/types/byte_set/types.ts` (`_Byte` public-closure helper), - `fjs/common/monoid/private.ts` and `fjs/rtti/data/private.ts` - (`private.ts`), `fjs/edag/proof.f.mjs` and `fjs/effects/proof.f.mjs` - (function-local proof typedefs), `fjs/edag/module.f.mjs` and - `fjs/media/json/schema/module.f.mjs` (recursive RTTI kept in place), - `todo/proof.f.mjs` (authored `.mjs` outside `fjs/`); `meta/module.f.mjs` - remains a documented option with no current instance. -- [x] Update root and `fjs/` `AGENTS.md` policy documentation; rewrite the - `fjs/fsc/README.md` typedef prescription; delete or narrow the blocked - `@internal` TODO; sweep all remaining Markdown documents for file-scope - typedef prescriptions and retarget each to the Stage 1 forms. - -#### Stage 2 — packaging cleanup - -- [ ] Exclude generated `private.d.ts` from the package with a `!**/private.d.ts` - negation in `package.json`'s `files`; leave `prepack` unchanged. -- [ ] Do not text-postprocess emitted declarations; validate semantic private - dependencies and clean-consumer type checking instead. -- [ ] Depend on the checkout-less packed-artifact type-check job rather than - specifying it here: the job belongs to - [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md), - and the artifact hand-off and job-ordering edge it rests on belong to - [`../ci/todo/ci-integration-tests.md`](../ci/todo/ci-integration-tests.md). - What this design requires *of* that job, and what it must not lose: - - it type-checks **every** packed declaration, enumerated from the - installed artifact — a fixed import list cannot see a module that gains - a `private.ts` after the job is written, which is exactly the case this - stage exists to catch; - - it runs with no repository checkout, so nothing in the source tree can - stand in for an omitted `private.d.ts`; - - `skipLibCheck` stays `false`, or the check silently stops checking. - Adding a second package-validation path instead of completing that - fixture would put the private-types assertion somewhere the packaging - work does not own. -- [ ] Make that job a required check, so a reintroduced private dependency - blocks the merge queue rather than landing. -- [ ] Assert the tarball's contents (no `private.d.ts` inside) alongside that - job — a cheap complement to the semantic declaration check, never its - replacement. -- [ ] Prove each half can fail, with its own negative control — they fail on - opposite inputs, so one control cannot stand for both. Dropping the - `files` negation leaves `private.d.ts` *in* the tarball, where every - reference to it resolves: that reddens the contents assertion and leaves - the type-check green. The type-check's control is the reverse — a packed - declaration that references a private module the tarball does not carry - (a shipped declaration made to depend on `private.ts`, with the negation - still in place), which resolves in-repo and dangles once packed. Make it - a **source-level** violation — an exported binding whose signature names a - private type — not an edit to the packed output, so the control exercises - emit, packing and consumption together; measured end to end above. - Falsifiability and exhaustiveness are separate questions and were - measured separately, so keep them separate here too: - - *Can it fail?* Any module with a `private.ts` will do; measured in - `fjs/types/bigfloat`. - - *Is it exhaustive?* The violation has to land where a hand-written - import list would not look — a module with **no** `private.ts` today, - which means temporarily giving one to a module that has none. It must - also not be the package fixture, since any plausible import list names - that. Measured with `fjs/emergent_testing`. - Running only the first proves the check reports a dangling reference; it - says nothing about whether the file set was enumerated or hard-coded. -- [ ] Add fixtures covering packaging: retained non-semantic JSDoc `@import` - comments in emitted declarations, absent private artifacts in the tarball, - and a clean package consumer. -- [ ] Narrow the `fjs/fsc/README.md` leak tolerance: drop the `private.d.ts` - tolerance, keep the permanent `_` contract for `_` declarations that - still ship (`types.ts` helpers, exported `meta/module.f.mjs` constants). - -### Acceptance criteria - -#### Stage 1 — source restructuring - -- The public declaration surface is self-contained; no public declaration - semantically depends on `private.ts`. Generated `private.d.ts` files may - still ship until Stage 2 — the leak is consolidated, not yet removed. -- No authored `.mjs` anywhere in the repository contains a file-scope JSDoc - `@typedef`; function-local typedefs are allowed. -- `types.ts` contains the public declaration closure and does not depend on - `private.ts`. -- `private.ts`, when present, is an optional implementation tool rather than a - required companion. -- A subordinate module such as `meta/module.f.mjs`, when present, is an optional - metaprogramming/design tool rather than a special file role or requirement. -- The intra-directory dependency direction is preserved; assertions do not create - reverse edges merely for convenience. -- Private types/constants use leading `_`, even when linkage requires an export. -- Existing `module.f.mjs` discovery and coverage rules automatically include - `meta/module.f.mjs`; no metadata-specific coverage convention exists. -- Chosen public import-path moves are breaking migrations with importers/changelog - updated and no compatibility re-exports. -- Root `AGENTS.md` and `fjs/AGENTS.md` document the Stage 1 rules. -- No repository document prescribes creating file-scope JSDoc typedefs or - waiting for `@internal`/`stripInternal` — verified by a repo-wide search, - not by checking an enumerated list. The permanent `_` contract stays - documented, and the shipped `private.d.ts` tolerance stays documented until - Stage 2. - -#### Stage 2 — packaging cleanup - -- The public declaration/API surface is clean: no private type artifact that is - intended to be unshipped is present in the tarball. -- Generated `private.d.ts` files are excluded from the package by - `package.json`'s `files`, with `prepack` unchanged. -- Emitted declarations are not text-postprocessed; retained JSDoc `@import` - comments are allowed when they are non-semantic. -- The packed artifact has no semantic dependency on an unshipped private type - module, and every declaration it ships type-checks successfully. -- That check runs **in CI**, from the packed tarball handed over as an - artifact, in a job with **no repository checkout** and with `skipLibCheck` - left at its `false` default — the only arrangement in which a declaration - pointing at an omitted `private.d.ts` is an error rather than a silently - skipped library file or a resolution into the source tree. -- Its file set is derived from the installed artifact, so a module that gains a - `private.ts` after the job is written is checked without the job being - edited, and its compiler is the repository's exact pinned `typescript`, read - from the packed `package.json`, so the check cannot change verdict without a - change to this repository. -- That job never races the artifact upload — the ordering edge and the CI - generator's ability to express it are owned by - [`../ci/todo/ci-integration-tests.md`](../ci/todo/ci-integration-tests.md). -- That job is a required check, so the failure blocks the merge queue. -- Both halves are demonstrably falsifiable, each by the input that actually - breaks it: dropping the `files` negation reddens the contents assertion, and - a packed declaration depending on a private module the tarball does not carry - reddens the declaration type-check. -- Exhaustiveness is demonstrated separately from falsifiability, by a violation - in a module that has no `private.ts` today and is not the package fixture — - anywhere a fixed import list would already look proves only the latter. -- The CI job is generated from `fjs/ci/**`, so `npm run ci-update` reproduces - `.github/workflows/ci.yml` byte-identically. -- `fjs/fsc/README.md` no longer needs tolerance for a shipped `private.d.ts`, - since none ships, and still documents the permanent `_` contract: `_` names - emitted into shipped declarations are not API. - -### Related - -- [`../fsc/README.md`](../fsc/README.md) — the `_` contract and the remaining - `private.d.ts` tolerance Stage 2 retires. -- [`../../AGENTS.md`](../../AGENTS.md) — root repository policy. -- [`../AGENTS.md`](../AGENTS.md) — `fjs/`-specific file/dependency policy. -- [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) - — the packed-consumer CI fixture Stage 2 completes; it owns the - checkout-less type-check job this design depends on. -- [`../ci/todo/ci-integration-tests.md`](../ci/todo/ci-integration-tests.md) - — owns the `npm pack` artifact hand-off and the CI generator's job-ordering - edge that job rests on. -- jsdoc-typedef-strip-internal (retired; deleted with Stage 1, which supersedes - it) — the former wait-for-`@internal`/`stripInternal` strategy. -- [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) - — upstream JSDoc typedef stripping limitation; superseded as this design's - strategy, since no authored `.mjs` declares a typedef to strip. -- [`detect-unexported-types-referenced-by-exported-types.md`](./detect-unexported-types-referenced-by-exported-types.md) - — related declaration-leak detection. -- [`document-file-type-naming-conventions.md`](./document-file-type-naming-conventions.md) - — repository source-file roles. -- [`../../todo/migrate-typescript-to-mjs.md`](../../todo/migrate-typescript-to-mjs.md) - — current JavaScript/JSDoc migration and `_` convention. -- [`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) - — declaration emission and clean package validation. diff --git a/todo/blocked/jsdoc-typedef-doc-declaration-emit.md b/todo/blocked/jsdoc-typedef-doc-declaration-emit.md index 627687812..e92d338f6 100644 --- a/todo/blocked/jsdoc-typedef-doc-declaration-emit.md +++ b/todo/blocked/jsdoc-typedef-doc-declaration-emit.md @@ -1,7 +1,7 @@ ## JSDoc `@typedef` documentation is dropped by tsgo declaration emit -> Authored `.mjs` no longer carries file-scope `@typedef`s -> ([`../../fjs/todo/separate-private-types.md`](../../fjs/todo/separate-private-types.md)), +> Authored `.mjs` no longer carries file-scope `@typedef`s (root +> [`AGENTS.md`](../../AGENTS.md), [`fjs/AGENTS.md`](../../fjs/AGENTS.md) §3.2), > so no authored typedef documentation reaches declaration emit any more; this > upstream behavior matters again only if that rule is ever relaxed. @@ -213,8 +213,8 @@ Body: - [`todo/migrate-typescript-to-mjs.md`](../migrate-typescript-to-mjs.md) — "Typedef documentation does not survive declaration emit". -- [`../../fjs/todo/separate-private-types.md`](../../fjs/todo/separate-private-types.md) - — private-type placement; superseded the wait-for-`@internal` strategy. +- [`../../fjs/AGENTS.md`](../../fjs/AGENTS.md) §3.2 — private-type placement; + superseded the wait-for-`@internal` strategy. - [microsoft/TypeScript#43534](https://github.com/microsoft/TypeScript/issues/43534), [microsoft/TypeScript#61664](https://github.com/microsoft/TypeScript/issues/61664) — adjacent strada behaviors. diff --git a/todo/migrate-typescript-to-mjs.md b/todo/migrate-typescript-to-mjs.md index 6d29eb8bf..fdcf7c009 100644 --- a/todo/migrate-typescript-to-mjs.md +++ b/todo/migrate-typescript-to-mjs.md @@ -342,8 +342,8 @@ consumer all work; that is tracked in A named type migrating out of a `.f.ts` never becomes a **file-scope** JSDoc `@typedef` — authored `.mjs` files carry none, repository-wide (root -`AGENTS.md`; design in -[`../fjs/todo/separate-private-types.md`](../fjs/todo/separate-private-types.md)). +`AGENTS.md`; placement rules in +[`../fjs/AGENTS.md`](../fjs/AGENTS.md) §3.2). It lands in the sibling `types.ts` when it is part of the public declaration closure, in an optional sibling `private.ts` when it is implementation-private and separating it reads cleaner than inlining, inline in the annotations that @@ -398,14 +398,14 @@ plans to remove both. Hiding a type behind `_` to make its eventual removal cheaper gives up a real present-day API in exchange for a discount on a breaking change that should simply be documented when it happens. -Unshipping generated private declaration artifacts is the packaging stage of -[`../fjs/todo/separate-private-types.md`](../fjs/todo/separate-private-types.md); -the `_` contract itself is permanent. +Generated private declaration artifacts are not shipped +([`../fjs/fsc/README.md`](../fjs/fsc/README.md)); the `_` contract itself is +permanent, since `_` names still reach the declarations that do ship. #### Typedef documentation does not survive declaration emit > Since the repository-wide prohibition on file-scope `@typedef` in authored -> `.mjs` ([`../fjs/todo/separate-private-types.md`](../fjs/todo/separate-private-types.md)), +> `.mjs` (root [`AGENTS.md`](../AGENTS.md)), > named types live in `types.ts`/`private.ts`, whose documentation emits > through the normal TypeScript pipeline — so this loss no longer affects > authored code. The record below explains the behavior and why the @@ -1254,9 +1254,9 @@ person can re-check rather than re-derive. Counts are as of — broader package-publishing plan. - [`../fjs/fsc/README.md`](../fjs/fsc/README.md) — authoritative FunctionalScript extension and migration contract. -- [`../fjs/todo/separate-private-types.md`](../fjs/todo/separate-private-types.md) - — private-type placement rules and the packaging stage that unships - generated private declarations. +- [`../fjs/AGENTS.md`](../fjs/AGENTS.md) §3.2 — private-type placement rules; + [`../fjs/fsc/README.md`](../fjs/fsc/README.md) — why generated private + declarations are not packaged. - [microsoft/TypeScript#46407](https://github.com/microsoft/TypeScript/issues/46407) — upstream request for `stripInternal` support on JSDoc typedefs. - [`fjs-nanvm-integration.md`](./fjs-nanvm-integration.md) — existing compiler From 99abf684a1d71bf542db046973b8223236a263bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:15:33 +0000 Subject: [PATCH 324/370] changelog: entry for #1771 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- changelog/unreleased/1771.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog/unreleased/1771.md diff --git a/changelog/unreleased/1771.md b/changelog/unreleased/1771.md new file mode 100644 index 000000000..0a59abb8e --- /dev/null +++ b/changelog/unreleased/1771.md @@ -0,0 +1,5 @@ +- Generated `private.d.ts` files are no longer published: `package.json`'s + `files` excludes them, dropping 16 files from the package. They were + implementation-private by contract — `types.ts` may not depend on + `private.ts`, so no public declaration needed one — and the package's own + declarations are type-checked in CI as an outside consumer installs them. From 85f3780796789c5da67e24110d2e5317664904b9 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 18:15:42 -0700 Subject: [PATCH 325/370] todo: include TypeScript in node26 Nix shell --- fjs/ci/todo/typescript-ci-tool.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 6ea96a77f..5e80e7ce6 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -15,7 +15,7 @@ This task is intentionally limited to TypeScript. Keep `@types/node` in `devDepe Provision a pinned TypeScript version through the CI tool environment and remove `typescript` from the root `package.json` `devDependencies`. -Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job, packed-package check, and package publishing path do need the pinned compiler because they invoke `tsc` directly, install it for declaration validation, or invoke it through npm lifecycle scripts such as `prepack`. +Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job, its generated Node 26 Nix environment, packed-package check, and package publishing path do need the pinned compiler because they invoke `tsc` directly, provide the canonical development toolchain, install it for declaration validation, or invoke it through npm lifecycle scripts such as `prepack`. Local development must continue to support `tsc`, `npm test`, and `npm pack`: outside an environment that provides the compiler, developers install the pinned TypeScript globally so `tsc` is available on `PATH`. @@ -24,6 +24,7 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Add a pinned TypeScript version to the CI tool configuration. - [ ] Make the packed-package check read its compiler pin from that CI configuration instead of `package.json` so removing `devDependencies.typescript` does not remove `package-check`; update the related proofs for the new pin source. - [ ] Provision that TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). +- [ ] Add the pinned TypeScript package to the generated Node 26 Nix environment (`nodeNixJobs`) so the canonical development shell provides `tsc`; update its proofs/generated-flake expectations. - [ ] Provision the pinned TypeScript in the npm publishing workflow so `prepack` uses the intended compiler during `npm publish`. - [ ] Run `tsc` from `PATH` instead of relying on `npx tsc` / `node_modules/.bin/tsc`. - [ ] Remove `typescript` from the root `package.json` `devDependencies`, then run `npm run update` so `package-lock.json`, `deno.lock`, `bun.lock`, and generated CI files are all regenerated consistently. @@ -31,5 +32,6 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Update repository-owned developer/check documentation, including `CONTRIBUTING.md`, `AGENTS.md`, `fjs/AGENTS.md`, and `fjs/ci/README.md`: list TypeScript as a developer tool where appropriate, document installing the pinned version globally for local development, and replace required `npx tsc` instructions with `tsc`. - [ ] Update the Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks have the pinned TypeScript on `PATH` without relying on the root devDependency. - [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily and their frozen-lock installs still succeed. +- [ ] Verify the generated Node 26 Nix shell provides the pinned `tsc` and can run the canonical type-check/package commands without a local TypeScript devDependency. - [ ] Verify `package-check` remains generated and validates the packed declarations with the CI-configured compiler pin. - [ ] Verify `tsc`, `npm test`, `npm pack`, and the npm publish path work in every environment that is documented or responsible for those checks. From a444be75495f13c156f28be1a261a8496946096a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:16:12 +0000 Subject: [PATCH 326/370] spec/datajs: specify the DataJS interchange format Stage 1a of todo/parser-serializer-restructure.md. spec/datajs/README.md is the normative text: lexical and document grammar, data model (leaf set, JS object semantics, the DAG and why it is acyclic), const-name exclusions, serialization with the byte-deterministic normalized form, the JSON and JavaScript relationships, files and media type, and the rationale for the decisions that look arbitrary without it. Every claim about JavaScript in the document was checked against an engine rather than recalled. The 48-word exclusion list is what node actually rejects as `const = 1` in module code; `undefined`, `NaN` and `Infinity` are accepted there, which is why they need a separate rule. Array-index key ordering really does stop at 2^32-2: `4294967294` sorts ahead of a string key and `4294967295` does not. A source-literal `{"__proto__": x}` replaces the prototype while `{["__proto__"]: x}` is an own property. The document opens with a status section, because it specifies a target: the current compiler rejects `const a=[];export default[a,a];` at the `;`, and `spec/README.md` uses "DJS" for the wider subset it accepts today. That document now points here and says which name means what. Conformance vectors are the other half of stage 1 and are tracked in spec/datajs/todo/conformance-vectors.md, including the bootstrapping constraint that the corpus cannot be stored in the format it is meant to bring up. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/README.md | 8 + spec/datajs/README.md | 419 ++++++++++++++++++++++++ spec/datajs/todo/conformance-vectors.md | 65 ++++ todo/parser-serializer-restructure.md | 21 +- 4 files changed, 506 insertions(+), 7 deletions(-) create mode 100644 spec/datajs/README.md create mode 100644 spec/datajs/todo/conformance-vectors.md diff --git a/spec/README.md b/spec/README.md index 7739ac2cf..3079efc4e 100644 --- a/spec/README.md +++ b/spec/README.md @@ -22,6 +22,14 @@ the compiler accepts today is DJS, so this document specifies DJS; the layer a feature belongs to is a statement about where it lands, not about what is implemented now. +**DJS here is not DataJS.** [`spec/datajs/`](./datajs/README.md) specifies +**DataJS**, a much narrower interchange format: JSON extended from a tree to a +DAG and nothing else, with `;`-terminated statements, no `import`, no comments, +no identifier keys and no trailing commas. The data subset described in *this* +document is wider and is what the compiler accepts today. The two converge as +[`todo/parser-serializer-restructure.md`](../todo/parser-serializer-restructure.md) +proceeds. + ## Principles **Compatibility with JavaScript.** FunctionalScript is a subset of JavaScript, diff --git a/spec/datajs/README.md b/spec/datajs/README.md new file mode 100644 index 000000000..cc4ede54f --- /dev/null +++ b/spec/datajs/README.md @@ -0,0 +1,419 @@ +# DataJS + +DataJS is JSON extended from a **tree** to a **directed acyclic graph**, and +nothing else. A document is a JavaScript module: a list of `const` statements +naming values that are used more than once, and one `export default` naming +the value the document denotes. + +```js +const _0=[1,2];export default {"a":_0,"b":_0}; +``` + +Read as JSON this would be two equal arrays. Read as DataJS it is **one** +array named twice — that single difference is the whole language. + +The format is meant to be implementable from this document in an afternoon, +and then to stop changing. Everything that is not needed for the DAG property +belongs to [FunctionalScript](../README.md), not here. + +## Status + +**This document specifies a target, not the current implementation.** The +FunctionalScript compiler in this repository does not accept DataJS today: it +separates statements by newline, so it rejects the `;` this format requires. +The work that closes the gap is staged in +[`todo/parser-serializer-restructure.md`](../../todo/parser-serializer-restructure.md). + +Note the two nearby uses of "DJS". [`spec/README.md`](../README.md) uses it for +the data subset the compiler accepts **today**, which is wider than DataJS: +it has `import`, comments, identifier keys, trailing commas, and newline +separation. This document specifies **DataJS**, the narrow interchange format. +"DJS" survives only as an informal abbreviation of DataJS. + +## Principles + +1. **Derive behavior from JavaScript.** DataJS ⊂ FunctionalScript ⊂ + JavaScript, where `⊂` means *accepted with identical meaning*. A subset may + reject what its superset accepts; it must never accept something and mean + something different by it. Where a rule could be argued either way, the + answer is whatever a JavaScript engine does. +2. **One spelling wherever possible.** Fewer spellings mean fewer decisions + for an implementer and fewer disagreements between implementations. +3. **No semantics on invisible characters.** Two files that render identically + in every editor must not mean different things. This is why statements end + with a visible `;` rather than a line terminator. +4. **Restate, do not cite.** Where DataJS depends on a JavaScript algorithm, + this document writes the algorithm out. An implementer should not need + ECMA-262 open beside it. + +## Grammar + +### Whitespace + +Whitespace is exactly JSON's: **space** (U+0020), **tab** (U+0009), **LF** +(U+000A), **CR** (U+000D). It is insignificant everywhere and may appear +between any two tokens. + +Every other character JavaScript treats as whitespace or a line terminator is +**rejected**: U+2028, U+2029, no-break space, form feed, vertical tab, and a +byte order mark, wherever they appear outside a string literal. Accepting them +would import a taxonomy no implementer of a data format should have to know. + +Whitespace is *required* only between two adjacent word tokens — `const a`, +`export default x`. Everywhere else it is optional, so every document has a +one-line spelling. + +A document is UTF-8. It has no BOM. + +### Tokens + +```text +punctuator ::= '{' | '}' | '[' | ']' | ':' | ',' | '=' | ';' +word ::= 'const' | 'export' | 'default' + | 'true' | 'false' | 'null' | 'undefined' + | 'NaN' | 'Infinity' | id +``` + +A `-` is **not an operator**. It is part of the token that follows it, and +only where that token is a number, a bigint, or `Infinity`. `-NaN`, +`-undefined`, `-true` and a bare `-` are rejected. + +#### Strings + +A string is a JSON string, unchanged: double quotes, the escapes `\"` `\\` +`\/` `\b` `\f` `\n` `\r` `\t` and `\uXXXX`, and any other character except an +unescaped `"`, an unescaped `\`, or a code point below U+0020. + +Single quotes, template literals, `\x` escapes, `\u{…}` escapes and line +continuations are rejected. JSON has none of them. + +#### Numbers + +A number is a JSON number, unchanged: + +```text +number ::= '-'? int frac? exp? +int ::= '0' | [1-9] [0-9]* +frac ::= '.' [0-9]+ +exp ::= [eE] [-+]? [0-9]+ +``` + +No hexadecimal, no leading `+`, no leading or trailing decimal point, no +numeric separators, no leading zeros. + +`NaN`, `Infinity` and `-Infinity` are **words**, not number syntax, and are +values of the number type. `-0` is ordinary number syntax and denotes negative +zero. + +#### Bigints + +A bigint is its **own production**, not a suffix on the number grammar: + +```text +bigint ::= '-'? int 'n' +``` + +`int` is the number grammar's integer part, so there is no fraction, no +exponent and no leading zero. This is not a stylistic restriction: JavaScript +rejects `1.5n` and `1e2n`, so a "number followed by `n`" rule would accept +text that is not JavaScript. + +`-0n` is accepted and denotes `0n`; bigint has no negative zero. + +#### Identifiers + +```text +id ::= [A-Za-z_$] [A-Za-z0-9_$]* +``` + +ASCII only. JavaScript allows the whole Unicode identifier grammar; DataJS +does not, so that no implementation needs Unicode identifier tables. + +An `id` is used for `const` names and for references to them. See +[Const names](#const-names) for the words that may not be used. + +### Document + +```text +document ::= const* export +const ::= 'const' id '=' value ';' +export ::= 'export' 'default' value ';' + +value ::= 'null' | 'true' | 'false' | 'undefined' + | 'NaN' | 'Infinity' | '-Infinity' + | number | bigint | string + | array | object | id + +array ::= '[' (value (',' value)*)? ']' +object ::= '{' (member (',' member)*)? '}' +member ::= key ':' value +key ::= string | '[' '"__proto__"' ']' +``` + +**Every statement ends with `;`**, `export default` included. There is no +per-statement exception and no empty statement: `;;` and a stray `;` are +rejected. + +There are **no trailing commas**, **no comments**, and **no `import`**. A +DataJS document is closed: it denotes its value with no reference to any other +file. + +`export default` is required, and is the last statement. + +## Data model + +A document denotes a **directed acyclic graph** of values. + +### Leaves + +| Leaf | Written | In JSON | +|---|---|:-:| +| null | `null` | ✅ | +| boolean | `true`, `false` | ✅ | +| string | `"a"` | ✅ | +| number | `-42.5`, `3e2`, `-0` | ✅ | +| number | `NaN`, `Infinity`, `-Infinity` | ❌ | +| bigint | `34n`, `-34n` | ❌ | +| undefined | `undefined` | ❌ | + +Number round trips are exact in the sense of JavaScript's `Object.is`: +`-0` reads back as `-0` and not `0`, and `NaN` reads back as `NaN`. + +### Objects + +An object's members are its own properties, and DataJS follows JavaScript's +object semantics exactly. Two rules, both observable, and both restated here +because an implementation in a language with ordered dictionaries will +otherwise get them wrong: + +**Duplicate keys — last value, first position.** In `{"a":1,"b":2,"a":3}` the +key `a` holds `3`, and it comes *before* `b`, because the first occurrence +fixed its position. + +**Key order — array indices first.** A key that is an *array index* — a +canonical decimal string for an integer `0 ≤ n < 2^32 − 1`, with no sign, no +leading zero and no fraction — comes before every other key, and index keys +are ordered by numeric value. All remaining keys follow in first-occurrence +order. So `{"2":0,"1":0}` denotes an object whose keys are observably `"1"`, +`"2"` — in that order — in every JavaScript engine, and an implementation in +another language must reorder identically. + +**`__proto__` has one spelling.** The only way to write that key is the +computed form: + +```js +export default {["__proto__"]:1}; +``` + +A bare `__proto__` key and the string form `{"__proto__":1}` are **rejected**, +because JavaScript reads them as an instruction to replace the object's +prototype rather than as data. The computed form is an ordinary own property +in JavaScript, so it means in DataJS what it means in JavaScript — the whole +reason it is the accepted spelling. + +### Sharing, and why the graph is acyclic + +A `const` names a value; a reference to that name denotes **the same node**, +not an equal copy. In + +```js +const _0=[];export default [_0,_0]; +``` + +the two elements are one array. An implementation in a language with +reference identity must preserve that; an implementation in a language without +it must document what it does instead. + +A reference may name only a **previously declared** `const`. That single rule +gives the format three properties for free: a document is acyclic by +construction, it can be parsed in one pass, and no implementation needs cycle +detection to read one. + +Cycles are therefore unrepresentable. A serializer handed a cyclic value +rejects it rather than inventing a spelling — see +[Serialization](#serialization). + +## Const names + +A `const` name is an `id`, each name is bound at most once, and two sets of +words are excluded. + +**Excluded because JavaScript rejects them as a binding.** Module code is +strict, and JavaScript refuses `const = 1` for each of these. Accepting +one would produce a "DataJS document" that is not JavaScript at all: + +```text +arguments await break case catch class const +continue debugger default delete do else enum +eval export extends false finally for function +if implements import in instanceof interface let +new null package private protected public return +static super switch this throw true try +typeof var void while with yield +``` + +**Excluded because DataJS reads them as values.** JavaScript *permits* +`const undefined = 1`, and afterwards `undefined` means that const. A subset +that bound the name but kept treating the word as a literal would accept a +document and mean something different by it, so DataJS rejects the binding: + +```text +undefined NaN Infinity +``` + +Everything else matching `id` is available, including the contextual keywords +`async`, `as`, `from`, `get`, `of` and `set`: DataJS has no syntax in which +they are special, and JavaScript accepts them as bindings in module code. + +## Serialization + +Any serializer that emits a valid document is conforming; whitespace, which +values are hoisted into a `const`, and the names of those consts are all free +choices. A reader must accept every valid document however it is spelled. + +### What may be serialized + +A serializer's input is an ordinary programmatic value, which may be outside +the data model. Anything outside it is **rejected as an error**, never +approximated: + +- a leaf outside the leaf set — a function, a symbol, a `Date`, or any other + non-plain object; +- a hole in a sparse array, which is not an `undefined` element; +- a symbol-keyed own property, or an accessor property (reading a getter is + an effect); +- a cycle. + +Silently substituting `null`, dropping a member, or expanding a hole is what +`JSON.stringify` does; DataJS does not, because the result would be a valid +document denoting a different value. + +### Normalized form + +Normalized form is one specific serializer, chosen so that a value has +**exactly one** byte spelling. It is optional — a conforming implementation +need not produce it — but an implementation that claims to produce normalized +DataJS must produce these bytes. + +**Layout.** One line. Whitespace appears only where two word tokens would +otherwise merge (`const a`, `export default x`). No indentation, no trailing +newline. + +**Which values become consts.** A value is hoisted into a `const` if and only +if it is an object or an array reachable more than once **by reference +identity**. Primitives are always written inline: primitive sharing is not +observable, and counting them by value would raise the `0`/`-0` and `NaN` +questions that the `Object.is` guarantee forbids answering either way. + +**Order and names.** Emit consts in **post-order of one depth-first traversal** +of the exported value — arrays in element order, objects in observable key +order, descending into a shared node only the first time it is met. Assign +names `_0`, `_1`, … in emission order. Post-order is what makes a node's +dependencies land before the node itself, which the declare-before-use rule +requires. For `root = [parent, parent, child]` where `child` is inside +`parent`, `child` finishes first: it is `_0` and `parent` is `_1`. + +**Numbers** are spelled by ECMAScript's `ToString(Number)` — the algorithm +`String(x)` implements — with one exception: `-0`, which `ToString` spells +`0` and normalized DataJS spells `-0`. `ToString` is fully deterministic, so +there is no "shortest spelling" tie to break: `1e3` is spelled `1000`, and the +uppercase `1E3` never arises. `NaN`, `Infinity` and `-Infinity` are spelled by +those words. + +**Bigints** are their full decimal digits followed by `n`, never exponent +notation — which would read back as a number. + +**Strings** are spelled by ECMAScript's `QuoteJSONString`, the algorithm +`JSON.stringify` uses for a string: the escapes `\"` `\\` `\b` `\t` `\n` `\f` +`\r` where they apply, any other code point below U+0020 as `\u00` followed by +two **lowercase** hex digits, an unpaired surrogate as `\u` followed by four +lowercase hex digits, and every other code point literally. `/` is never +escaped. + +**Object keys** are emitted in the observable order defined above, with +duplicates already collapsed — a normalized document never contains a +duplicate key. + +Tooling should *default* to a readable layout — one statement per line, +indented containers — which is simply one of the many valid non-normalized +spellings. Normalized output is something a caller asks for, typically to hash +or compare documents. + +## Relationship to JSON + +**Every JSON value is a DataJS value. No JSON document is a DataJS document** — +a DataJS document is a JavaScript module, which a JSON document is not. + +The conversion is textual: + +```text +"export default " + json + ";" +``` + +with one exception: a bare `"__proto__"` key must be rewritten to +`["__proto__"]`, since DataJS rejects the string spelling. For JSON containing +no `__proto__` key, plain concatenation is exactly a valid DataJS document. + +The reverse direction is partial. A DataJS document converts to JSON only when +it uses no leaf JSON lacks (`undefined`, `NaN`, the infinities, bigint) and no +value is shared — JSON cannot express the sharing, and emitting the value twice +denotes a different graph. + +## Relationship to FunctionalScript and JavaScript + +A DataJS document is a valid FunctionalScript module and a valid JavaScript +module, denoting the same value in all three. FunctionalScript adds `import`, +comments, identifier keys, functions and the rest of the language; DataJS is +what remains when everything not needed to write a value graph is removed. + +The inclusion is a testable claim, not a stylistic one, and the conformance +suite states it as: every accepted DataJS document parses in FunctionalScript +to the same graph, and every DataJS document is accepted by a JavaScript +engine with the same result. + +## Files and media type + +Recognized extensions: `.data.js`, `.data.mjs`, `.d.js`, `.d.mjs`. + +Tools emit **`.data.js`**. Use `.data.mjs` where a file must resolve as an ES +module regardless of the enclosing package's `"type"` field. + +The media type is **`application/datajs`**, mirroring `application/json`, with +charset UTF-8 implied. + +One practical caveat: that type describes the *data*. A server that expects a +browser to `import` the file must send a JavaScript MIME type (`text/javascript`) +instead, because a module load rejects any other type. The two uses do not +conflict — they are different requests for the same bytes — but a document +served for both needs a deliberate choice. + +## Conformance + +An implementation conforms if it accepts every document this specification +accepts, rejects every document it rejects, and denotes the graph described +here. The machine-readable accept / reject / round-trip corpus that decides +this is +[`spec/datajs/todo/conformance-vectors.md`](./todo/conformance-vectors.md); +until it lands, this prose is the only statement of conformance. + +## Rationale + +**Why `;` and not a newline?** A lone CR is a JavaScript line terminator, so is +U+2028; newline separation drags that taxonomy into a data format, and makes +two files that look identical mean different things. `;` is visible, has one +spelling, and lets a document minify to a single line — which is what makes a +DataJS document embeddable in a JSON string, streamable one-per-line, and +writable as a one-line test fixture. + +**Why no comments?** They are trivia with no denotation, and every one of them +is a decision for a normalizer. A format whose purpose is to be normalized and +compared does not need them. + +**Why no identifier keys or trailing commas?** They are second spellings of +things that already have one. FunctionalScript has them; DataJS is where the +spellings are spent carefully. + +**Why is the DAG the only extension?** Because it is the one thing JSON cannot +express at all — not a convenience but a class of value. Everything else +JSON's tree already covers, and every further feature would be another version +of the format for implementers to track. diff --git a/spec/datajs/todo/conformance-vectors.md b/spec/datajs/todo/conformance-vectors.md new file mode 100644 index 000000000..6660469fd --- /dev/null +++ b/spec/datajs/todo/conformance-vectors.md @@ -0,0 +1,65 @@ +## DataJS conformance vectors + +**Priority:** P2 +**Status:** open + +### Problem + +[`spec/datajs/README.md`](../README.md) states conformance in prose. Prose +cannot be executed, so nothing stops the reference implementation and the +specification from drifting apart — which is exactly what happened to the +`fjs/fsc` grammar that stage 2 deleted, unproven and unimported. + +The stages that follow all need the same corpus: +stage 3 (JSON's self-contained tokenizer) must prove JSON's accepted language +is unchanged, stage 4 (`fjs/media/datajs`) must prove the parser and +serializer implement *the spec* rather than each other, and stage 6 must prove +the DataJS ⊂ FunctionalScript ⊂ JavaScript subset laws. One corpus, four +consumers. + +### Proposal + +A machine-readable corpus with three parts: + +- **accept** — document text plus the graph it denotes, including the sharing. + Cases: every leaf (`-0`, `NaN`, `±Infinity`, bigint, `undefined`), the + `["__proto__"]` key, duplicate keys (last value, first position), array-index + key ordering, one-line and readable spellings of the same value, empty + containers, deep nesting, and shared nodes reached by several paths. +- **reject** — document text plus what is wrong with it. Cases: a missing or + non-final `export default`, a missing `;`, `;;`, a trailing comma, a comment, + an `import`, an identifier key, a bare or string `"__proto__"` key, `1.5n`, + `1e2n`, `01n`, `-NaN`, `-undefined`, a bare `-`, a forward or unbound + reference, a rebound name, each excluded const name, single quotes, `\x` and + `\u{…}` escapes, U+2028/U+2029/NBSP/FF/BOM outside a string. +- **normalize** — an input document and the exact bytes normalized form must + produce: const hoisting by reference identity, post-order `_0`, `_1`, … + naming, `ToString(Number)` spelling with the `-0` exception, + `QuoteJSONString` escaping, observable key order, one-line layout. + +The corpus is data, not code, so it can be read by an implementation in any +language. Store it as DataJS once `fjs/media/datajs` can read it; until then +JSON, since the corpus must be readable by the very implementation it tests — +a corpus that can only be read by a working DataJS parser cannot be used to +bring one up. + +Two properties worth proving directly rather than case by case: every +**accept** document parses in FunctionalScript to the same graph, and every +**accept** document is accepted by a JavaScript engine with the same result. +Those are the subset laws, and they can run over the whole accept set. + +### Tasks + +- [ ] Choose the corpus's own encoding and location, per the bootstrapping + constraint above. +- [ ] Write the accept, reject and normalize sets covering the cases listed. +- [ ] Add the two whole-set subset-law checks. +- [ ] Point stages 3, 4 and 6 at the corpus as their proof source. +- [ ] `npx tsc`, `fjs test`. + +### Related + +- [`spec/datajs/README.md`](../README.md) — the specification the corpus + makes executable; its Conformance section links back here. +- [`todo/parser-serializer-restructure.md`](../../../todo/parser-serializer-restructure.md) + — the plan; this is the second half of its stage 1. diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 495c68ac5..9d041fbd1 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -236,11 +236,15 @@ combined marker would encode a redundant fact. Each stage lands green and independently; `fjs compile` keeps working throughout. -1. **Spec** — `spec/datajs/`: format spec (grammar as BNF text, data model, - rationale) plus the normalization section, and the conformance test - vectors (accept, reject, round-trip) that every later stage runs against. - Decides the one remaining deferred detail: the media type. (The - canonical layout is decided: one line — see **Serialization** above.) +1. **Spec** — `spec/datajs/`. The specification itself is **done**: + [`spec/datajs/README.md`](../spec/datajs/README.md) carries the grammar, + data model, const-name exclusions, serialization and normalized form, + the JSON and JavaScript relationships, and the rationale, and proposes + `application/datajs` as the media type (noting that a file served for a + browser `import` must instead be sent as a JavaScript MIME type). The + conformance vectors are the remaining half, tracked in + [`spec/datajs/todo/conformance-vectors.md`](../spec/datajs/todo/conformance-vectors.md); + stages 3, 4 and 6 consume them. 2. **Dead code — done.** `fjs/fsc/bnf.f.mjs` and `fjs/fsc/json.f.mjs` are deleted rather than salvaged: both were dead (no importer) and unproven, the JSON half duplicated `deterministic` in `fjs/bnf/testlib.f.mjs` rule @@ -313,8 +317,11 @@ throughout. ### Tasks -- [ ] Stage 1: write `spec/datajs/` and the conformance vectors; file its - co-located todo. +- [x] Stage 1a: write `spec/datajs/README.md`; disambiguate the older "DJS" + in [`spec/README.md`](../spec/README.md), which names the wider subset + the compiler accepts today. +- [ ] Stage 1b: the conformance vectors — + [`conformance-vectors`](../spec/datajs/todo/conformance-vectors.md). - [x] Stage 2: dead `fjs/fsc` grammar deleted; its todo file removed and the citations in [207](../fjs/bnf/todo/207-bnf-semantic-actions.md) repointed at `fjs/bnf/testlib.f.mjs`. From 273199033672afdb92638c84574a0f2038009d19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:18:59 +0000 Subject: [PATCH 327/370] todo: allVoid is born list-shaped, not migrated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit allvoid-combinator's sketch spread allOk(...items.map(f)) — an arbitrary-length fan-out combinator that would rebuild the argument ceiling inside itself, the same correction allreduce-combinator already carries. The proposal now hands allOk the list, names the dependency on all-argument-limit's list-shaped operation, and gains the matching task; all-argument-limit's migration task names both future combinators as consumers born list-shaped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/all-argument-limit.md | 7 ++++++- fjs/effects/todo/allvoid-combinator.md | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/fjs/effects/todo/all-argument-limit.md b/fjs/effects/todo/all-argument-limit.md index a9ca0c50d..1f79782f9 100644 --- a/fjs/effects/todo/all-argument-limit.md +++ b/fjs/effects/todo/all-argument-limit.md @@ -95,7 +95,12 @@ list-shaped operation is worth having in the same change. the wrapper narrows the break to the handlers, which is the argument for keeping it. - [ ] Move every interpreter and fixture to it in one change, and every spread site in the - table above with them. + table above with them. Future combinators scheduled after this issue are + consumers too, born list-shaped rather than migrated: + [allvoid-combinator](./allvoid-combinator.md) and + [allreduce-combinator](./allreduce-combinator.md) both say so in their + proposals — an arbitrary-length fan-out combinator with a spread in its + body would rebuild this ceiling inside itself. - [ ] Prove a fan-out above the current ceiling — the number itself is engine-specific, so the proof asserts that a large fan-out completes rather than asserting the ceiling. diff --git a/fjs/effects/todo/allvoid-combinator.md b/fjs/effects/todo/allvoid-combinator.md index 506ba5dae..acef9177a 100644 --- a/fjs/effects/todo/allvoid-combinator.md +++ b/fjs/effects/todo/allvoid-combinator.md @@ -58,12 +58,22 @@ sites already spell it that way. export const allVoid = (f: (item: T) => Effect) => (items: readonly T[]): Effect => - mapStep(allOk(...items.map(f)), () => undefined) + mapStep(allOk(items.map(f)), () => undefined) ``` +The body hands `allOk` the *list*, not a spread: `allVoid` exists for +arbitrary-length fan-outs, which is exactly where `allOk(...items.map(f))` +would rebuild the engine argument ceiling +([all-argument-limit](./all-argument-limit.md)) inside the new combinator — +the same correction [allreduce-combinator](./allreduce-combinator.md) +carries. That makes this issue's landing depend on the list-shaped `allOk` +from that issue; until it lands, the spread spelling is the only one that +compiles, which is one more reason this issue is scheduled after the `All` +move rather than before it. + `NotImplemented` in the error channel is the runner's, inherited from `allOk`; `E` is the children's. Written with the standalone `step` instead — -`step(allOk(...items.map(f)), () => pureOk(undefined))` — it is the same effect +`step(allOk(items.map(f)), () => pureOk(undefined))` — it is the same effect said less directly; either works. Note `pureOk`, not `pure`: `pure` takes a `Result` (`pureOk = v => pure(ok(v))`), so `pure(undefined)` would yield a bare `undefined` where the chain expects `ok(undefined)`. Both spellings must also @@ -98,6 +108,10 @@ would discard precisely the failures this section exists to keep. `All`/`all`/`both` **and `allOk`** to `fjs/effects/all/module.f.mjs`. `allVoid` is built on `allOk`, so moving one without the other inverts the layering. +- [ ] Wait for [all-argument-limit](./all-argument-limit.md)'s list-shaped + `allOk`, and hand it the list: `allVoid` is an arbitrary-length + fan-out, so a spread in its body would rebuild the argument ceiling it + is called at (the note under the proposal). - [ ] Add `allVoid` there (next to `all`/`both`) with proof coverage — **not** to `fjs/effects/node/module.f.mjs`, per the note at the top of this issue. - [ ] Convert the three `mapStep(allOk(...), () => undefined)` call sites in From 3c91638dc3823e4c7157f037b9c8053918dadb88 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:20:13 +0000 Subject: [PATCH 328/370] rtti: ask the structural questions cheapest-first The undeclared-member check enumerates the value's keys, which is O(its size); the length bound reads one `length` and the absence pass consults the schema once per declared member. Asking the expensive one first made a value that either of the others answers pay for the enumeration anyway: an object with 500 000 undeclared keys and a missing required member took 1.2s in `validate`, where the absence alone settles it. Two changes. The undeclared check moves after the absence pass, so a value another question answers never enumerates -- 0ms on that case. And the gate asks the new `hasUndeclaredMember`, which stops at the first hit and reads no values, rather than the length of `undeclaredMembers`, which pairs every one; `undeclaredMembers` stays for the `rest` readers, which need the pairs. Where the enumeration is genuinely required -- 500 000 undeclared keys and a valid declared member -- that is 204ms against main's 786ms, and `Object.keys` alone is 185ms of it, so it is the floor rather than a choice. All three checks still precede every recursive read, which is what keeps an `or` of two arities linear. Precedence among them is now cheapest first: the length bound, then an illegal absence, then an undeclared member, then the members. Pinned in both proof suites; acceptance unchanged over the 1550-pair differential against main. Reported by Codex on the pull request. --- changelog/unreleased/1766.md | 9 ++++----- fjs/rtti/common/module.f.mjs | 27 +++++++++++++++++++++++++++ fjs/rtti/parse/module.f.mjs | 16 +++++++++++----- fjs/rtti/parse/proof.f.mjs | 2 ++ fjs/rtti/validate/module.f.mjs | 24 ++++++++++++++++-------- fjs/rtti/validate/proof.f.mjs | 4 ++++ 6 files changed, 64 insertions(+), 18 deletions(-) diff --git a/changelog/unreleased/1766.md b/changelog/unreleased/1766.md index 7836288da..ae6c89ccf 100644 --- a/changelog/unreleased/1766.md +++ b/changelog/unreleased/1766.md @@ -1,6 +1,5 @@ - `rtti`: `validate` and `parse` answer a closed tuple's or struct's structure - before reading any member — presence recorded, the container bounded by - length and by undeclared keys, then illegal absence settled — so an `or` of - two arities no longer walks shared operands once per arm. Acceptance is - unchanged; a container-level error now wins over an absent member's, and - both over an earlier member's. + before reading any member — the length bound, then an illegal absence, then + an undeclared member — so an `or` of two arities no longer walks shared + operands once per arm. Acceptance is unchanged; those three errors now win, + in that order, over a member's. diff --git a/fjs/rtti/common/module.f.mjs b/fjs/rtti/common/module.f.mjs index 85861c59a..510f5808a 100644 --- a/fjs/rtti/common/module.f.mjs +++ b/fjs/rtti/common/module.f.mjs @@ -334,6 +334,33 @@ export const undeclaredMembers = (declared, value) => { ] } +/** + * Whether `value` carries any member `declared` does not name — the same + * question {@link undeclaredMembers} answers, for a caller that needs only + * the yes or no. + * + * It exists to **stop early**. A closed container's gate asks whether there + * is an undeclared member at all, and building the list to ask its length + * makes a value with many of them pay for all of them: an object with 500 000 + * undeclared keys against a one-key struct spent over a second reading and + * pairing every one, where the first key already settles it. The key set + * still has to be materialized — JavaScript exposes no lazy own-key walk — + * but the values are not read and the scan stops at the first hit. + * + * `undeclaredMembers` stays for the `rest` readers, which need the pairs. + * + * @type {(declared: readonly string[], value: ReadonlyArray | StringMap) => boolean} + */ +export const hasUndeclaredMember = (declared, value) => { + /** @type {(k: string) => boolean} */ + const undeclared = k => !declared.some(d => d === k) + if (!commonIsArray(value)) { + return Object.keys(value).some(undeclared) + } + return readIndices(value).some(i => undeclared(String(i))) + || Object.keys(value).some(k => arrayIndex(k) === undefined && undeclared(k)) +} + /** * Whether `rtti` admits **absence** with `visited` already ruled out — the * recursive half of {@link admitsAbsence}, carrying the thunks on the current diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 33c91cd9a..4afdfa311 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -67,6 +67,7 @@ import { primitive0Validate, structSchemaEntries, tupleSchemaEntries, + hasUndeclaredMember, undeclaredMembers, verror, visit, @@ -340,13 +341,15 @@ const constContainerParse = if (!isContainer(value)) { return verror('unexpected value') } - // Presence, the bound, absence, then the reads. See the - // comment on the same shape in `../validate/module.f.mjs`, - // including what settling the shape first assumes of the value. + // Presence, the bound, absence, the undeclared check, then + // the reads. See the comment on the same shape in + // `../validate/module.f.mjs`, including what settling the shape + // first assumes of the value. const withPresence = rttiEntries.map(([k, t]) => /** @type {readonly[string, readonly[typeof t, boolean]]} */ ([k, [t, k in value]])) - // `fits` first, for the reason `../validate`'s copy states. - if (!fits(value, declared.length) || undeclaredMembers(declared, value).length !== 0) { + // Cheapest structural question first, for the reason + // `../validate`'s copy states. + if (!fits(value, declared.length)) { return verror('unexpected value') } // Absence before any read, for the reason `../validate`'s @@ -359,6 +362,9 @@ const constContainerParse = acc => acc, ) if (a[0] === 'error') { return a } + if (hasUndeclaredMember(declared, value)) { + return verror('unexpected value') + } const r = eachEntry( withPresence, (k, [t, present]) => { diff --git a/fjs/rtti/parse/proof.f.mjs b/fjs/rtti/parse/proof.f.mjs index 676060933..8af9d3e40 100644 --- a/fjs/rtti/parse/proof.f.mjs +++ b/fjs/rtti/parse/proof.f.mjs @@ -465,6 +465,8 @@ export const proof = { // an undeclared key likewise, which is the struct kind's // half of the rule — see `../validate/proof.f.mjs` assertErrorPath([])(parse({ a: number })({ a: 'bad', b: 1 })) + // and between the two, the absent member wins + assertErrorPath(['a'])(parse({ a: number })({ b: 1 })) }, // Nor is a key that is no position at all. nonIndexKeyRejected: () => diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index 27ca65830..dcc21f452 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -96,6 +96,7 @@ import { primitive0Validate, structSchemaEntries, tupleSchemaEntries, + hasUndeclaredMember, undeclaredMembers, verror, visit, @@ -217,8 +218,9 @@ const constContainerValidate = } // The container's **shape** is settled before any member is // read: presence is recorded, the container is bounded, an - // illegal absence is rejected — and only then are the members - // read, from the flags already recorded. + // illegal absence is rejected, an undeclared member is + // rejected — and only then are the members read, from the flags + // already recorded. // // That order is what makes an `or` of two arities linear // instead of 2^depth, which is the shape a schema uses to say a @@ -238,12 +240,15 @@ const constContainerValidate = // in "What the readers assume of a value" in `../README.md`. const withPresence = rttiEntries.map(([k, v]) => /** @type {readonly[string, readonly[typeof v, boolean]]} */ ([k, [v, k in value]])) - // `fits` first: it reads one `length`, where `undeclaredMembers` - // enumerates every member the value and its prototypes carry. On - // an oversized array the two answer alike, so the cheap one has - // to ask first — a million-element array against `[number]` is - // 1ms in this order and 1.5s in the other. - if (!fits(value, declared.length) || undeclaredMembers(declared, value).length !== 0) { + // The three structural questions run cheapest-first, since any + // of them settles the container and none of them recurses: + // `fits` reads one `length`; the absence pass consults the + // schema once per *declared* member; only the undeclared check + // enumerates the **value's** keys, which is O(its size) and has + // no lazy form in JavaScript — `Object.keys` on 500 000 keys is + // 185ms whether or not the scan stops at the first. Asking it + // last means a value another question answers never pays it. + if (!fits(value, declared.length)) { return verror('unexpected value') } // Reaching an illegal absence through the reading walk would @@ -259,6 +264,9 @@ const constContainerValidate = acc => acc, ) if (a[0] === 'error') { return a } + if (hasUndeclaredMember(declared, value)) { + return verror('unexpected value') + } const r = eachEntry( withPresence, (k, [v, present]) => { diff --git a/fjs/rtti/validate/proof.f.mjs b/fjs/rtti/validate/proof.f.mjs index d6f9cff0e..4c69b207e 100644 --- a/fjs/rtti/validate/proof.f.mjs +++ b/fjs/rtti/validate/proof.f.mjs @@ -886,6 +886,10 @@ export const proof = { // can settle the arm whose value has too much const one = { a: number } for (const read of [v, p]) { assertErrorPath([])(read(one)({ a: 'bad', b: 1 })) } + // between the two structural answers the absent member wins, since + // it is the cheap one: it consults the schema once per declared + // member, where the undeclared check enumerates the value's keys + for (const read of [v, p]) { assertErrorPath(['a'])(read(one)({ b: 1 })) } // and a value that fits is read as before for (const read of [v, p, d]) { assertOk(read(t)([42])) } }, From 470b32992c684a95dfbfac69e251f2b80fa610cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:21:50 +0000 Subject: [PATCH 329/370] package: add the `files` negation this branch asserts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The negation was written, measured, and then lost before it was committed. The negative control set `files` back to its unnegated value to show the assertion fails, and restored it with `git checkout -- package.json` — which restores from HEAD, not from the working tree, so it discarded the change instead of the control's. Every measurement in the previous commit was real when taken; none of them was re-taken against the tree that was committed. So the branch enforced the assertion without making it satisfiable: `prepack` emits the 16 `private.d.ts`, the positive `**/*.d.ts` allowlist packs them, and the new step exits 1 before the upload. CI had not yet dispatched a run, so the red never appeared; Codex read the diff and found it. Verified against the committed tree this time, not the working tree it was measured in: the generated node26 steps run clean, 663 files packed, 0 `private.d.ts`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c06f77097..3e291f0f0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "**/*.js", "**/*.d.ts", "**/*.mjs", - "**/*.d.mts" + "**/*.d.mts", + "!**/private.d.ts" ], "description": "FunctionalScript is a purely functional subset of JavaScript", "scripts": { From ea03db9a702ead6479bf5f63e12e9a6bc60bd245 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:21:59 +0000 Subject: [PATCH 330/370] spec/datajs: serializer conformance is graph equivalence Two review findings, both real holes in the serialization section. Serializer conformance said only that the output must be a valid document, so `export default null;` qualified for any input, and hoisting was listed among the free choices. Output must denote the input graph; given that, layout, const names and hoisting a singly-reachable value are free, but hoisting a value reachable more than once is mandatory, since a const is the only way the format expresses sharing. The rejection list also missed properties the syntax cannot carry. An array with an extra own property is the sharp case: `a=[1]; a.meta=2` has own keys 0, length and meta, and array syntax holds only elements, so JSON.stringify drops meta silently and so would a DataJS serializer following the old list. Non-enumerable own properties are the same class of bug. Both are now rejected. The conformance vectors gain matching sets: serializer-reject cases, and graph-equivalence cases so a serializer cannot pass by emitting merely valid output. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/datajs/README.md | 33 +++++++++++++++++++------ spec/datajs/todo/conformance-vectors.md | 9 +++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/spec/datajs/README.md b/spec/datajs/README.md index cc4ede54f..a25f4cfcd 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -267,9 +267,20 @@ they are special, and JavaScript accepts them as bindings in module code. ## Serialization -Any serializer that emits a valid document is conforming; whitespace, which -values are hoisted into a `const`, and the names of those consts are all free -choices. A reader must accept every valid document however it is spelled. +A serializer conforms when its output is a valid document **that denotes the +input graph** — the same values, with the same sharing. Validity alone is not +the bar: `export default null;` is a perfectly valid document and almost never +the right answer. + +Given that, the remaining choices are free: whitespace and layout, the names +of the consts, and whether a value reachable only once is hoisted into one. + +Hoisting a value reachable **more than once is not a free choice**. Writing it +inline at each occurrence yields a document denoting equal copies rather than +one shared node — a different graph — so a serializer must emit a `const` for +it. A `const` is the only way the format expresses sharing at all. + +A reader must accept every valid document however it is spelled. ### What may be serialized @@ -280,13 +291,19 @@ approximated: - a leaf outside the leaf set — a function, a symbol, a `Date`, or any other non-plain object; - a hole in a sparse array, which is not an `undefined` element; -- a symbol-keyed own property, or an accessor property (reading a getter is - an effect); +- an own property this format cannot write: a symbol key, an accessor + property (reading a getter is an effect), or a non-enumerable property — + each would otherwise vanish from the output; +- an array carrying any own property besides its elements and `length`. + `const a=[1]; a.meta=2` has the own keys `0`, `length` and `meta`, and array + syntax holds only elements, so `meta` has nowhere to go; - a cycle. -Silently substituting `null`, dropping a member, or expanding a hole is what -`JSON.stringify` does; DataJS does not, because the result would be a valid -document denoting a different value. +Every one of these is a case where the obvious implementation quietly produces +a document denoting something else. `JSON.stringify` substitutes `null` for a +function, expands a hole to `null`, drops a symbol-keyed member, and drops that +`meta` without a word. DataJS rejects instead, because a silently wrong +document is worse than no document. ### Normalized form diff --git a/spec/datajs/todo/conformance-vectors.md b/spec/datajs/todo/conformance-vectors.md index 6660469fd..9b4e82fec 100644 --- a/spec/datajs/todo/conformance-vectors.md +++ b/spec/datajs/todo/conformance-vectors.md @@ -32,6 +32,15 @@ A machine-readable corpus with three parts: `1e2n`, `01n`, `-NaN`, `-undefined`, a bare `-`, a forward or unbound reference, a rebound name, each excluded const name, single quotes, `\x` and `\u{…}` escapes, U+2028/U+2029/NBSP/FF/BOM outside a string. +- **serializer reject** — programmatic inputs a serializer must refuse rather + than approximate: a function, symbol or `Date` leaf, a sparse-array hole, a + symbol-keyed, accessor or non-enumerable own property, an array carrying an + own property beyond its elements and `length` (`a=[1]; a.meta=2`), and a + cycle. Each is a case where the obvious implementation emits a valid + document denoting something else. +- **graph equivalence** — an input graph and the documents that do and do not + denote it, so a serializer cannot pass by emitting merely *valid* output: + `[a,a]` with one shared `a` is not `export default [[],[]];`. - **normalize** — an input document and the exact bytes normalized form must produce: const hoisting by reference identity, post-order `_0`, `_1`, … naming, `ToString(Number)` spelling with the `-0` exception, From bd4cf24ecfedc011dfa367766b02db88c77d8313 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:23:28 +0000 Subject: [PATCH 331/370] todo: name both callables so allVoid builds in every permitted branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the variadic wrapper keeps the published allOk name, a body passing one array cannot type-check against it. all-argument-limit now pins the naming: a kept wrapper keeps all/allOk (that is what narrows the break) with the list-shaped operation exported beside it (allList/allOkList); a dropped wrapper hands the old names to the list shape. allVoid's note says which callable its sketch names under each branch — the call shape is one array argument either way. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/all-argument-limit.md | 12 +++++++++++- fjs/effects/todo/allvoid-combinator.md | 18 +++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/fjs/effects/todo/all-argument-limit.md b/fjs/effects/todo/all-argument-limit.md index 1f79782f9..b595e69ee 100644 --- a/fjs/effects/todo/all-argument-limit.md +++ b/fjs/effects/todo/all-argument-limit.md @@ -69,7 +69,17 @@ the sequential plan the traversal performs no `all`, so no browser implements it The variadic spelling is nicer at the two-or-three-effect call sites that motivated it (`both`, hand-written fan-outs in proofs), so a wrapper that keeps that shape over the -list-shaped operation is worth having in the same change. +list-shaped operation is worth having in the same change. **Both callables get +unambiguous names, whichever branch is taken**: if the wrapper is kept it keeps +the published `all`/`allOk` names (that is what narrows the break, per the task +below) and the list-shaped operation is exported beside it under its own names +(say `allList`/`allOkList`); if the wrapper is dropped, the list shape takes +the old names. Every arbitrary-length fan-out — the traversal sites in the +table, and combinators born after this issue +([allvoid-combinator](./allvoid-combinator.md), +[allreduce-combinator](./allreduce-combinator.md)) — calls the *list-shaped* +callable by whichever name this decision lands on, so those designs are +buildable under every permitted outcome. ### Alternatives considered diff --git a/fjs/effects/todo/allvoid-combinator.md b/fjs/effects/todo/allvoid-combinator.md index acef9177a..a069d5a9e 100644 --- a/fjs/effects/todo/allvoid-combinator.md +++ b/fjs/effects/todo/allvoid-combinator.md @@ -61,15 +61,19 @@ export const allVoid = mapStep(allOk(items.map(f)), () => undefined) ``` -The body hands `allOk` the *list*, not a spread: `allVoid` exists for -arbitrary-length fan-outs, which is exactly where `allOk(...items.map(f))` -would rebuild the engine argument ceiling +The body hands the *list-shaped* callable the list, not a spread: `allVoid` +exists for arbitrary-length fan-outs, which is exactly where +`allOk(...items.map(f))` would rebuild the engine argument ceiling ([all-argument-limit](./all-argument-limit.md)) inside the new combinator — the same correction [allreduce-combinator](./allreduce-combinator.md) -carries. That makes this issue's landing depend on the list-shaped `allOk` -from that issue; until it lands, the spread spelling is the only one that -compiles, which is one more reason this issue is scheduled after the `All` -move rather than before it. +carries. `allOk` in the sketch names that list-shaped operation under +all-argument-limit's naming rule: it is `allOk` itself if the variadic +wrapper is dropped, and the list-shaped sibling (`allOkList` in that issue's +sketch) if the wrapper keeps the published names — either way the body's +call shape is one array argument. That makes this issue's landing depend on +the list-shaped operation from that issue; until it lands, the spread +spelling is the only one that compiles, which is one more reason this issue +is scheduled after the `All` move rather than before it. `NotImplemented` in the error channel is the runner's, inherited from `allOk`; `E` is the children's. Written with the standalone `step` instead — From bf2ab4b0c631386d876c63a144d7eb7a5704eaf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:26:27 +0000 Subject: [PATCH 332/370] spec/datajs: the summary now matches the normative body Two review findings, both cases of the opening overselling the format. "JSON extended to a DAG, and nothing else" contradicted the leaf table two sections later, which also adds undefined, bigint, NaN, the infinities and -0. An implementer trusting the summary would omit them. The summary now names both extensions and says why each earns its place: sharing is a class of value JSON has no syntax for, and the extra leaves are values JSON.stringify silently destroys - NaN and the infinities to null, -0 to 0, an undefined member dropped while an undefined element becomes null, and a bigint throws. All verified, including the array versus member asymmetry. "const statements naming values that are used more than once" was a reference-count rule the grammar does not impose and the serialization section explicitly permits breaking. const a=[];export default a; is valid. The summary now says a const is how sharing is written, not a claim about use, and points at normalized form as the only place references are counted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/datajs/README.md | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/spec/datajs/README.md b/spec/datajs/README.md index a25f4cfcd..39d706e3f 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -1,19 +1,33 @@ # DataJS -DataJS is JSON extended from a **tree** to a **directed acyclic graph**, and -nothing else. A document is a JavaScript module: a list of `const` statements -naming values that are used more than once, and one `export default` naming -the value the document denotes. +DataJS is JSON with two extensions, and no other additions: + +1. a value may be **shared**, so a document denotes a directed acyclic graph + where JSON denotes a tree; +2. the leaf set gains the JavaScript values JSON cannot spell — `undefined`, + `bigint`, `NaN`, `Infinity`, `-Infinity` and `-0`. + +The first is the reason the format exists; the second is what it costs to +round-trip a JavaScript value honestly. + +A document is a JavaScript module: `const` statements naming values, then one +`export default` naming the value the document denotes. ```js const _0=[1,2];export default {"a":_0,"b":_0}; ``` -Read as JSON this would be two equal arrays. Read as DataJS it is **one** -array named twice — that single difference is the whole language. +Read as JSON that would be two equal arrays. Read as DataJS it is **one** +array named twice — the sharing is the point. + +A `const` is *how* sharing is written, not a claim about use: a name may be +referenced any number of times, including once or not at all, and the grammar +imposes no reference count. What counts a value's references is +[normalized form](#normalized-form), which is one serializer's rule rather +than a rule about which documents are valid. The format is meant to be implementable from this document in an afternoon, -and then to stop changing. Everything that is not needed for the DAG property +and then to stop changing. Everything not needed to write a value graph belongs to [FunctionalScript](../README.md), not here. ## Status @@ -430,7 +444,11 @@ compared does not need them. things that already have one. FunctionalScript has them; DataJS is where the spellings are spent carefully. -**Why is the DAG the only extension?** Because it is the one thing JSON cannot -express at all — not a convenience but a class of value. Everything else -JSON's tree already covers, and every further feature would be another version -of the format for implementers to track. +**Why only these two extensions?** Both are things JSON cannot express at +all, rather than conveniences. Sharing is a class of value JSON has no syntax +for; the extra leaves are values a JavaScript program holds and JSON silently +destroys — `JSON.stringify` turns `NaN` and the infinities into `null` and +`-0` into `0`, drops an `undefined` member while turning an `undefined` array +element into `null`, and throws outright on a `bigint`. Everything else JSON's +tree already covers, and each further feature would be another version of the +format for implementers to track. From 2c98320586678bbb8f86c6308fdb0a45f8579c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:27:13 +0000 Subject: [PATCH 333/370] rtti: bound the container before building the presence list The presence list is one entry per declared member, so a large schema paid for the whole thing before the constant-time length check could reject an oversized value: a 500 000-position tuple schema against a 500 001-element array took about half a second, where `length` alone settles it. Now under 1ms. The list preceded the bound only because an earlier revision snapshotted presence before `length` was read, to catch a hostile getter deleting a member. That defence is gone -- the readers assume DJS values -- so nothing holds the order any more. Acceptance unchanged over the 1550-pair differential against main. Reported by Codex on the pull request. --- fjs/rtti/parse/module.f.mjs | 6 +++--- fjs/rtti/validate/module.f.mjs | 30 ++++++++++++++++-------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 4afdfa311..7d3dfc878 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -341,17 +341,17 @@ const constContainerParse = if (!isContainer(value)) { return verror('unexpected value') } - // Presence, the bound, absence, the undeclared check, then + // The bound, presence, absence, the undeclared check, then // the reads. See the comment on the same shape in // `../validate/module.f.mjs`, including what settling the shape // first assumes of the value. - const withPresence = rttiEntries.map(([k, t]) => - /** @type {readonly[string, readonly[typeof t, boolean]]} */ ([k, [t, k in value]])) // Cheapest structural question first, for the reason // `../validate`'s copy states. if (!fits(value, declared.length)) { return verror('unexpected value') } + const withPresence = rttiEntries.map(([k, t]) => + /** @type {readonly[string, readonly[typeof t, boolean]]} */ ([k, [t, k in value]])) // Absence before any read, for the reason `../validate`'s // copy of this comment gives: reaching an illegal absence // through the reading walk restores the exponential. diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index dcc21f452..be469367d 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -217,10 +217,9 @@ const constContainerValidate = return verror('unexpected value') } // The container's **shape** is settled before any member is - // read: presence is recorded, the container is bounded, an - // illegal absence is rejected, an undeclared member is - // rejected — and only then are the members read, from the flags - // already recorded. + // read: it is bounded, presence is recorded, an illegal absence + // is rejected, an undeclared member is rejected — and only then + // are the members read, from the flags already recorded. // // That order is what makes an `or` of two arities linear // instead of 2^depth, which is the shape a schema uses to say a @@ -238,19 +237,22 @@ const constContainerValidate = // and the assumption the readers are written under. What that // gives up for a value built by arbitrary JavaScript is stated // in "What the readers assume of a value" in `../README.md`. - const withPresence = rttiEntries.map(([k, v]) => - /** @type {readonly[string, readonly[typeof v, boolean]]} */ ([k, [v, k in value]])) - // The three structural questions run cheapest-first, since any - // of them settles the container and none of them recurses: - // `fits` reads one `length`; the absence pass consults the - // schema once per *declared* member; only the undeclared check - // enumerates the **value's** keys, which is O(its size) and has - // no lazy form in JavaScript — `Object.keys` on 500 000 keys is - // 185ms whether or not the scan stops at the first. Asking it - // last means a value another question answers never pays it. + // The structural questions run cheapest-first, since any of + // them settles the container and none of them recurses: + // `fits` reads one `length`; the absence pass probes and + // consults the schema once per *declared* member; only the + // undeclared check enumerates the **value's** keys, which is + // O(its size) and has no lazy form in JavaScript — + // `Object.keys` on 500 000 keys is 185ms whether or not the + // scan stops at the first. So a value one question answers + // never pays for the ones after it, and the constant-time + // bound precedes even the presence list, which is one entry + // per declared member and so is the schema's size. if (!fits(value, declared.length)) { return verror('unexpected value') } + const withPresence = rttiEntries.map(([k, v]) => + /** @type {readonly[string, readonly[typeof v, boolean]]} */ ([k, [v, k in value]])) // Reaching an illegal absence through the reading walk would // first recurse into the members that come before it, and those // are the operands the longer arm shares — so the two arms would From 6eeaf057a76dc10f682ad33e59032a6b82826b56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:28:01 +0000 Subject: [PATCH 334/370] todo: allReduce is built on allOk with a parameterized error channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sketch folded raw all's continuation values as R, but all answers readonly Result[] — the failures arrive inside the value — so the monoid either fails to type-check or aggregates failure tuples as data, where its named consumers (registration, dev loading) propagate failures through allOk today. The proposal now builds on the list-shaped allOk (per all-argument-limit's naming rule), parameterizes E, and inherits NotImplemented from the runner; allvoid-combinator's caveat about the old sketch is updated to record it as superseded rather than current. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/allreduce-combinator.md | 44 +++++++++++++++--------- fjs/effects/todo/allvoid-combinator.md | 11 +++--- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/fjs/effects/todo/allreduce-combinator.md b/fjs/effects/todo/allreduce-combinator.md index d883149ff..dbb297bba 100644 --- a/fjs/effects/todo/allreduce-combinator.md +++ b/fjs/effects/todo/allreduce-combinator.md @@ -15,28 +15,38 @@ The pattern `step(all(...xs.map(f)), rs => pure(rs.reduce(op, init)))` — fan o ```ts export const allReduce = - ( - f: (item: T) => Effect, + ( + f: (item: T) => Effect, ) => (op: (a: R) => (b: R) => R) => (init: R) => - (items: List): Effect => - step( - all(...toArray(items).map(f)), - rs => pure(rs.reduce((a, b) => op(b)(a), init))) + (items: List): Effect => + mapStep( + allOk(toArray(items).map(f)), + rs => rs.reduce((a, b) => op(b)(a), init)) ``` -**The body must call the list-shaped operation directly once -[all-argument-limit](./all-argument-limit.md) lands** — the spread above is -exactly the unbounded-spread shape that issue exists to remove, and a -combinator built for arbitrarily long lists must not become another instance -of the ceiling. Until then the spread inherits the documented limit. - -Note the standalone `step`: `all(...)` returns a raw `Effect`, which is plain -data with no methods, so `all(...).step(...)` — as an earlier draft of this -issue wrote it — would not compile. If -[map-step-combinator](./map-step-combinator.md) lands first, the body is -`mapStep(all(...toArray(items).map(f)), rs => rs.reduce(...))`. +**Built on `allOk`, not on raw `all`, and the error channel is a parameter.** +`all`'s continuation receives `readonly Result[]` — the children's +failures arrive *inside* the value — so a monoid folding those elements as +`R` either does not type-check or aggregates failure tuples as data. An +earlier sketch of this issue did exactly that. `allOk` collapses the list to +`readonly R[]` and lifts the first failure into the effect's error channel, +which is how every named consumer below already behaves at its existing +`allOk` call sites; `NotImplemented` is the runner's, inherited from `allOk`, +and `E` is the children's. + +**The body hands `allOk` the list, not a spread**, per +[all-argument-limit](./all-argument-limit.md)'s naming rule (`allOk` above +names the list-shaped callable — `allOk` itself if the variadic wrapper is +dropped, the list-shaped sibling if it is kept): a combinator built for +arbitrarily long lists must not become another instance of the ceiling that +issue removes. This issue therefore lands after all-argument-limit; until +then only the variadic spelling compiles. + +Note the standalone `mapStep`: `allOk(…)` returns a raw `Effect`, which is +plain data with no methods, so `allOk(…).step(…)` — as an earlier draft of +this issue wrote it — would not compile. `op` must be **commutative** — results may arrive in any order when the runner schedules sub-effects in parallel. diff --git a/fjs/effects/todo/allvoid-combinator.md b/fjs/effects/todo/allvoid-combinator.md index a069d5a9e..2b7f859d1 100644 --- a/fjs/effects/todo/allvoid-combinator.md +++ b/fjs/effects/todo/allvoid-combinator.md @@ -101,10 +101,13 @@ no host API in it. The three call sites become `allVoid(e => registerOne(t, e))(sub)` etc. If [allreduce-combinator](./allreduce-combinator.md) lands first, consider deriving `allVoid` from `allReduce` with a unit monoid instead of -duplicating the `allOk(...map)` core — but only once `allReduce` is itself -built on `allOk`. As proposed it folds over `all(...)`, so its monoid receives -the children's `Result`s as ordinary values, and a unit monoid over those -would discard precisely the failures this section exists to keep. +duplicating the shared core — its proposal is now built on the list-shaped +`allOk`, so its monoid receives plain `R`s and the first failure travels the +error channel, which is exactly what a unit monoid needs. (An earlier sketch +of that issue folded over raw `all(...)`, whose monoid would have received +the children's `Result`s as ordinary values — a unit monoid over those would +discard precisely the failures this section exists to keep; that sketch is +recorded as superseded there.) ### Tasks From 0da4c8842459cffbbe20ca82c75ae40df1e8f4ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:28:31 +0000 Subject: [PATCH 335/370] ci: drop the packed-contents assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It guarded a dropped `files` negation, which the packed-declaration type-check cannot see: with `private.d.ts` shipped, every reference to it resolves and that job is green. But the consequence it prevented is declaration noise in the tarball — the state shipped until this branch, and one the `_` contract already tolerates — not a broken package. The check that catches breakage is the type-check, and it stays. Against that, the cost was real and kept arriving. It produced three defects in three commits: it enforced an exclusion the branch had lost, so nothing could pass; its predicate was a suffix test over the whole path, which also flags `notprivate.d.ts` — a name the negation deliberately keeps, so the job would have failed on a legal file and banned a filename nothing documents; and its own doc comment could not contain `**/` without ending the block. Root AGENTS.md §6: "Leaving the check undone is the better trade against that complexity. A rule no available tool can express stays written down and unenforced. That is honest, and cheaper than machinery whose failures are silent." The negation is one line in `package.json` and losing it is a visible diff in review. `fjs/ci/**` and `.gitignore` are byte-identical to main again; what remains on this branch is the negation, the documentation, and the retired design. `fjs/ci/todo/f-mjs-package-support.md` records that a dropped negation is now unguarded, and why that was accepted. 3479/3479, coverage 100%, tsc clean, ci-update round-trips. Package still packs 663 files with no `private.d.ts`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- .github/workflows/ci.yml | 5 +---- .gitignore | 3 --- fjs/ci/node/module.f.mjs | 23 +---------------------- fjs/ci/proof.f.mjs | 28 +--------------------------- fjs/ci/todo/f-mjs-package-support.md | 15 ++++++++++----- 5 files changed, 13 insertions(+), 61 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 897741d89..89567b3d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -508,10 +508,7 @@ "run": "npm run cov" }, { - "run": "npm pack --json > pack.json" - }, - { - "run": "node -e \"const f=JSON.parse(require('fs').readFileSync('pack.json','utf8'))[0].files.map(x=>x.path).filter(x=>x.endsWith('private.d.ts'));if(f.length!==0){console.error('packed private declarations:',f);process.exit(1)}\"" + "run": "npm pack" }, { "uses": "actions/upload-artifact@v7.0.1", diff --git a/.gitignore b/.gitignore index bc4587791..656c3d22a 100644 --- a/.gitignore +++ b/.gitignore @@ -129,9 +129,6 @@ _* **/*.d.mts **/*.d.ts -# `npm pack --json` output, read by the packed-contents assertion -/pack.json - test-results/ index.html diff --git a/fjs/ci/node/module.f.mjs b/fjs/ci/node/module.f.mjs index 8e8ef2a36..8783bd2bd 100644 --- a/fjs/ci/node/module.f.mjs +++ b/fjs/ci/node/module.f.mjs @@ -72,19 +72,6 @@ const node24Steps = [ ] /** @type {readonly MetaStep[]} */ -const packListing = /** @type {const} */ ('pack.json') - -/** - * Fails if the package ships a generated `private.d.ts`. - * - * `node` rather than a text search over the listing: the paths arrive as JSON - * and are compared as whole filenames, so nothing here can mistake a path that - * merely contains the name for one that ends in it. Root `AGENTS.md` §6 asks - * for a tool that parses what it checks; here that tool is the runtime this - * repository is written in, already running in every job. - */ -const noPackedPrivateDeclarations = `node -e "const f=JSON.parse(require('fs').readFileSync('${packListing}','utf8'))[0].files.map(x=>x.path).filter(x=>x.endsWith('private.d.ts'));if(f.length!==0){console.error('packed private declarations:',f);process.exit(1)}"` - const node26Steps = [ ...nodeInstall(node.default), test({ run: 'npm run ci-update' }), @@ -95,15 +82,7 @@ const node26Steps = [ test({ run: "! grep -rnE '^(/\\*\\*.*@typedef|\\s\\* *@typedef)' --include='*.mjs' --exclude-dir=node_modules ." }), test({ run: 'npx tsc' }), test({ run: 'npm run cov' }), - // `--json` so the assertion below reads npm's own account of what it packed - // rather than re-deriving it. The tarball is still written; only `--dry-run` - // suppresses that. - test({ run: `npm pack --json > ${packListing}` }), - // The complement to the packed-declaration type-check, which cannot see - // this: with `private.d.ts` shipped, every reference to it resolves and the - // type-check is green. The two fail on opposite inputs, so neither stands - // in for the other. Measured both ways before this landed. - test({ run: noPackedPrivateDeclarations }), + test({ run: 'npm pack' }), // Hands the tarball to a job that has no checkout, which is the only place // the package can be checked as a consumer sees it. `if-no-files-found` // must be `error`: the default warns and uploads nothing, so a consuming diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index 241de192c..ea035434f 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -230,37 +230,11 @@ export const proof = { assert(job['runs-on'] !== undefined, 'expected runs-on') assert(job.steps.length > 0, 'expected steps') }, - // The packed-contents half of the private-declaration guard. The packed - // *declaration* type-check cannot see a dropped `files` negation: with - // `private.d.ts` shipped, every reference to it resolves and that job is - // green. Both were measured on the input that breaks each before this - // landed, and they are opposite inputs, so neither substitutes for the - // other. - noPackedPrivateDeclarations: () => { - const gha = run(false) - const job = gha.jobs[`node${major(node.default)}`] - assert(job !== undefined, 'expected the canonical Node job') - const packIndex = job.steps.findIndex(step => step.run?.startsWith('npm pack') === true) - const checkIndex = job.steps.findIndex( - step => step.run?.includes("endsWith('private.d.ts')") === true) - assert(checkIndex !== -1, 'expected the packed private-declaration check') - // It reads what packing produced, so it cannot run before packing. - assert(checkIndex > packIndex, 'expected the check to follow npm pack') - // A check that never fails is indistinguishable from one that passes. - assert( - job.steps[checkIndex]?.run?.includes('process.exit(1)') === true, - 'expected a non-zero exit on a packed private declaration') - // `--json` is what makes the listing machine-readable; without it the - // check reads prose and matches nothing, which is silently green. - assert( - job.steps[packIndex]?.run?.includes('--json') === true, - 'expected the pack listing emitted as JSON') - }, packageArtifact: () => { const gha = run(false) const job = gha.jobs[`node${major(node.default)}`] assert(job !== undefined, 'expected the canonical Node job') - const packIndex = job.steps.findIndex(step => step.run?.startsWith('npm pack') === true) + const packIndex = job.steps.findIndex(step => step.run === 'npm pack') const uploadIndex = job.steps.findIndex( step => step.uses === `actions/upload-artifact@${actions['actions/upload-artifact']}`) assert(packIndex !== -1, 'expected npm pack') diff --git a/fjs/ci/todo/f-mjs-package-support.md b/fjs/ci/todo/f-mjs-package-support.md index 297d0cd61..4186010be 100644 --- a/fjs/ci/todo/f-mjs-package-support.md +++ b/fjs/ci/todo/f-mjs-package-support.md @@ -248,11 +248,16 @@ emission, `npm pack`, and a clean consumer. too and proves nothing about enumeration. Measured end to end with `fjs/emergent_testing`, which had no `private.ts`: given one, plus an exported binding whose signature names it, the job exits 2 with - `TS2307` on the packed declaration. The same violation with the - `files` negation dropped is **green**, because the private - declaration then ships and the reference resolves — which is why the - packed-contents assertion in the Node job is a separate check and not - a restatement of this one. + `TS2307` on the packed declaration. + + The same violation with the `files` negation dropped is **green**, + because the private declaration then ships and the reference + resolves. So this job does not detect a dropped negation, and nothing + else does either: an assertion over the packed listing was written for + that and removed as not worth its complexity — what it caught was + declaration noise in the tarball, which the `_` contract already + tolerates, not a broken package. The negation is one line in + `package.json` and losing it is a visible diff in review. Scope: the fixture exercises the supported, fully erased `import type` form only. The forbidden inline `import { type X }` / `import * as` / side-effect forms are a documented one-time measurement From 46f2082b4af54a017fcf0f5398dd7f8dca7a02a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:30:39 +0000 Subject: [PATCH 336/370] todo: local `npx tsc` skips a `.mjs` that has a generated declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generated `.d.mts` beside a `.mjs` shadows it: TypeScript reads the declaration and never checks the source. Declarations are gitignored, so CI clones without them and checks the sources, while a working tree that has run `prepack` once keeps them — and `npx tsc` then reports success for code it did not open. Found the hard way on this branch. An earlier commit inserted a const between a `/** @type {readonly MetaStep[]} */` annotation and the declaration it belonged to, so the annotation landed on a string. Local `npx tsc`: exit 0. CI: TS2322 on that line. Same tree and same compiler; deleting the generated declarations is the only difference, and it turns the local run red too. Filed rather than fixed, because the fix is a real choice — clear declarations in the gate and pay a cold emit every time, emit to a `declarationDir` and change the package layout, or document the trap and leave the tool alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- ...-skips-mjs-with-a-generated-declaration.md | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 todo/local-tsc-skips-mjs-with-a-generated-declaration.md diff --git a/todo/local-tsc-skips-mjs-with-a-generated-declaration.md b/todo/local-tsc-skips-mjs-with-a-generated-declaration.md new file mode 100644 index 000000000..4a77649d9 --- /dev/null +++ b/todo/local-tsc-skips-mjs-with-a-generated-declaration.md @@ -0,0 +1,67 @@ +## local-tsc-skips-mjs-with-a-generated-declaration. `npx tsc` passes locally on a `.f.mjs` error CI reports + +**Priority:** P2 +**Status:** open + +### Problem + +A generated `.d.mts` beside a `.mjs` shadows it: TypeScript reads the +declaration and does not check the source. Generated declarations are +gitignored, so CI clones without them and checks the sources; a working tree +that has run `prepack` even once keeps them, and from then on `npx tsc` reports +success for source it never opened. + +The failure mode is silent and confidence-shaped. `npx tsc` is what +`CONTRIBUTING.md` and every gate in this repository ask a contributor to run +before pushing, and it exits 0. + +Measured on [#1771](https://github.com/functionalscript/functionalscript/pull/1771), +which pushed a real type error past a clean local run. The same tree, same +compiler, one file deleted between the two runs: + +| Tree | `npx tsc` | +| --- | --- | +| with generated `.d.mts` present | exit 0, no diagnostics | +| declarations deleted first | `fjs/ci/node/module.f.mjs(75,7): error TS2322` | + +CI caught it, which is the system working — but a cycle later than a +contributor's own check should have, and the local pass is what made the push +look validated. + +Declaration *emit* has the same shape: `prepack` does not overwrite an existing +`.d.mts`, so a stale one survives every regeneration until it is deleted. That +half is harmless in CI for the same reason — a fresh clone has none — but it is +why the check half goes unnoticed. + +### Proposal + +No design agreed. The options are not equivalent and the choice is about who +pays: + +- **Clear declarations in the gate.** Whatever a contributor is told to run + deletes generated `.d.ts` / `.d.mts` first. Correct, and costs a full + re-emit on every local check. +- **Emit to an output directory.** `declarationDir`, so generated declarations + never sit beside their sources and cannot shadow them. Changes packaging + layout, `files`, and every consumer path — a large change for this. +- **Document it and leave the tool alone.** Cheapest, and honest; it makes the + trap known rather than absent, which §6 of the root `AGENTS.md` argues is + sometimes the better trade. + +Worth measuring before choosing: how long a from-scratch declaration emit takes, +since that number decides whether the first option is tolerable. + +### Tasks + +- [ ] Measure a cold `prepack` emit. +- [ ] Choose among the three and apply it. +- [ ] Until then, say in `CONTRIBUTING.md` that a local `npx tsc` does not check + a `.mjs` whose generated declaration exists, and how to get a real answer. + +### Related + +- [`../package.json`](../package.json) — `prepack` runs the emit, then re-checks + with declarations present. +- [`../.gitignore`](../.gitignore) — why CI never sees a stale declaration. +- [`../fjs/ci/node/module.f.mjs`](../fjs/ci/node/module.f.mjs) — `node26` runs + `npx tsc` on a fresh clone, which is why it disagreed. From c6de63950a8bc1cffc233ed86f76c0ce24ceead7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:33:12 +0000 Subject: [PATCH 337/370] spec/datajs: restate ToString, fix the array-length self-contradiction Four review findings. The non-enumerable rejection I added last round rejects every array: length is non-enumerable on all of them. It is now exempt by name, since it is not a member and the element list carries it implicitly. The number spelling named ToString(Number) without restating it, against this document's own "restate, do not cite" principle, and the gap is not theoretical: JavaScript writes 1e20 as 100000000000000000000 and Python writes 1e+20, so two implementations could hash the same value differently while both believing they conform. The algorithm is now written out with its 21 and -6 thresholds and its exponent formatting, and the boundary cases are pinned in the vectors todo. Readers had an escape hatch: a host without reference identity could "document what it does instead", which permitted flattening shared nodes into equal copies while claiming conformance. Such a reader owes an explicit graph representation instead; documenting the flattening does not excuse it. Hoisting said "reachable more than once", which is ambiguous where paths and references diverge: for root=[p,p] with p=[c], c has one incoming reference but two paths. The count is incoming reference occurrences, as the staged design intended, and the example is now in the text. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/datajs/README.md | 51 +++++++++++++++++++------ spec/datajs/todo/conformance-vectors.md | 6 ++- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/spec/datajs/README.md b/spec/datajs/README.md index 39d706e3f..94fbb7861 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -234,9 +234,12 @@ not an equal copy. In const _0=[];export default [_0,_0]; ``` -the two elements are one array. An implementation in a language with -reference identity must preserve that; an implementation in a language without -it must document what it does instead. +the two elements are one array, and a conforming reader must produce a +representation in which they remain one node. In a host with reference +identity that is automatic. In a host without it, the reader owes an explicit +representation — handles, indices into a node table, whatever the host offers +— because a reader that hands back two equal copies has returned a different +graph. Documenting that it flattens does not make it conforming. A reference may name only a **previously declared** `const`. That single rule gives the format three properties for free: a document is acyclic by @@ -307,7 +310,9 @@ approximated: - a hole in a sparse array, which is not an `undefined` element; - an own property this format cannot write: a symbol key, an accessor property (reading a getter is an effect), or a non-enumerable property — - each would otherwise vanish from the output; + each would otherwise vanish from the output. An array's own `length` is the + one exception: it is non-enumerable on every array, it is not a member, and + the syntax carries it implicitly in the element list; - an array carrying any own property besides its elements and `length`. `const a=[1]; a.meta=2` has the own keys `0`, `length` and `meta`, and array syntax holds only elements, so `meta` has nowhere to go; @@ -331,8 +336,12 @@ otherwise merge (`const a`, `export default x`). No indentation, no trailing newline. **Which values become consts.** A value is hoisted into a `const` if and only -if it is an object or an array reachable more than once **by reference -identity**. Primitives are always written inline: primitive sharing is not +if it is an object or an array whose **incoming reference occurrences** number +more than one, counting by reference identity. An occurrence is one place the +node appears: an array element, a member value, or the exported value. It is +not a count of root-to-node paths — for `root=[p,p]` with `p=[c]`, `p` has two +occurrences and is hoisted, while `c` has exactly one and stays inline, even +though two paths reach it. Primitives are always written inline: primitive sharing is not observable, and counting them by value would raise the `0`/`-0` and `NaN` questions that the `Object.is` guarantee forbids answering either way. @@ -344,12 +353,30 @@ dependencies land before the node itself, which the declare-before-use rule requires. For `root = [parent, parent, child]` where `child` is inside `parent`, `child` finishes first: it is `_0` and `parent` is `_1`. -**Numbers** are spelled by ECMAScript's `ToString(Number)` — the algorithm -`String(x)` implements — with one exception: `-0`, which `ToString` spells -`0` and normalized DataJS spells `-0`. `ToString` is fully deterministic, so -there is no "shortest spelling" tie to break: `1e3` is spelled `1000`, and the -uppercase `1E3` never arises. `NaN`, `Infinity` and `-Infinity` are spelled by -those words. +**Numbers** are spelled by ECMAScript's `ToString` applied to a Number — the +algorithm `String(x)` implements. It is restated here rather than cited, +because host formatters disagree on exactly these cases: JavaScript writes +`1e20` as `100000000000000000000` where Python writes `1e+20`. + +- `NaN` is `NaN`; the infinities are `Infinity` and `-Infinity`. +- A negative number is `-` followed by the spelling of its magnitude. `-0` is + the one departure from `ToString`, which spells it `0`; normalized DataJS + spells it `-0`. +- Otherwise pick integers `s`, `k`, `n`, with `k` as small as possible, such + that `10^(k-1) ≤ s < 10^k` and `s × 10^(n-k)` is exactly the value — `s` is + the shortest digit string that reads back as this number, `k` its length, + and `n` the position of the decimal point. Then: + - `k ≤ n ≤ 21` — the `k` digits, then `n − k` zeros: `100`; + - `0 < n ≤ 21` — the first `n` digits, `.`, the remaining `k − n`: `1.5`; + - `−6 < n ≤ 0` — `0.`, then `−n` zeros, then the `k` digits: `0.000001`; + - otherwise — the first digit, then `.` and the remaining `k − 1` digits + when `k > 1`, then `e`, then `+` or `-`, then the digits of `|n − 1|`: + `1e+21`, `1e-7`, `1.7976931348623157e+308`. + +The thresholds are exact and worth pinning: `1e20` is `100000000000000000000` +while `1e21` is `1e+21`, and `1e-6` is `0.000001` while `1e-7` is `1e-7`. A +positive exponent carries `+`; there is no uppercase `E` spelling, and no +"shortest form" tie left to break. **Bigints** are their full decimal digits followed by `n`, never exponent notation — which would read back as a number. diff --git a/spec/datajs/todo/conformance-vectors.md b/spec/datajs/todo/conformance-vectors.md index 9b4e82fec..c5823e666 100644 --- a/spec/datajs/todo/conformance-vectors.md +++ b/spec/datajs/todo/conformance-vectors.md @@ -44,7 +44,11 @@ A machine-readable corpus with three parts: - **normalize** — an input document and the exact bytes normalized form must produce: const hoisting by reference identity, post-order `_0`, `_1`, … naming, `ToString(Number)` spelling with the `-0` exception, - `QuoteJSONString` escaping, observable key order, one-line layout. + `QuoteJSONString` escaping, observable key order, one-line layout. Pin the + number thresholds explicitly — `1e20`, `1e21`, `1e-6`, `1e-7`, + `5e-324`, `1.7976931348623157e308` — since that is where a host's own + formatter diverges, and pin `root=[p,p]` with `p=[c]` so the hoisting count + is occurrences rather than paths. The corpus is data, not code, so it can be read by an implementation in any language. Store it as DataJS once `fjs/media/datajs` can read it; until then From 2aa7eba3d29d856a753549b522485ffdd1b8966e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:33:23 +0000 Subject: [PATCH 338/370] todo: cancellation boundaries and the timing experiment follow sequential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two linked issues still assumed the batched page. browser-test-controls checked its cancellation token 'between execution batches' — batching is gone, so the cooperative boundary is between one leaf's whole chain and the next, finer-grained than the batch boundary was; the token task now names it. timer-precision's accumulate-over-a-group idea objected to concurrency interleaving siblings' work into a group's span — that objection retires with the sequential plan, replaced by the smaller one that between-leaves overhead lands in the span; its prototype task now runs under the sequential traversal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/browser-test-controls.md | 14 ++++++++---- fjs/emergent_testing/todo/timer-precision.md | 22 +++++++++++++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/fjs/emergent_testing/todo/browser-test-controls.md b/fjs/emergent_testing/todo/browser-test-controls.md index 77c391782..2300477c9 100644 --- a/fjs/emergent_testing/todo/browser-test-controls.md +++ b/fjs/emergent_testing/todo/browser-test-controls.md @@ -36,8 +36,13 @@ Cancellation must be semantic, not merely visual. It should prevent unstarted proofs from running, ignore late module imports and proof completions from the cancelled run, and prevent that run from replacing a later run's progress, report, promise, or completion event. Work already executing in JavaScript -cannot always be interrupted; cancellation should be cooperative at module, -batch, and proof boundaries and document that limitation. +cannot always be interrupted; cancellation should be cooperative at module +and leaf boundaries and document that limitation. (When this was filed the +page ran batches, and the batch boundary was a natural check point; the +sequential plan in [share-browser-console-runner](share-browser-console-runner.md) +removes batching, so the boundary that remains is between one leaf's whole +chain — test, report, children — and the next, which is finer-grained than +the batch boundary was.) The final cancelled result needs a serializable status distinct from `failed` and `infrastructure-error`. Decide whether cancellation dispatches the existing @@ -55,8 +60,9 @@ module or a default query parameter. - [ ] Add a `Cancel` button and implement the inverse enabled/disabled states for `Run` and `Cancel`. - [ ] Add a per-run cancellation token or equivalent identity checked during - loading, between execution batches, and before every UI/global/event - publication. + loading, at each sequential leaf boundary (the between-batches check + this task once named — gone with batching, per the note above), and + before every UI/global/event publication. - [ ] Define the serializable cancelled report and completion-event behavior. - [x] Prove initial idle behavior and `Run`'s state transitions across loading, running, and both terminal outcomes; cancellation-related diff --git a/fjs/emergent_testing/todo/timer-precision.md b/fjs/emergent_testing/todo/timer-precision.md index edb330b27..ea88502ea 100644 --- a/fjs/emergent_testing/todo/timer-precision.md +++ b/fjs/emergent_testing/todo/timer-precision.md @@ -53,12 +53,17 @@ before changing the measurement. report is serializable and consumed by controllers, so a `resolution` field would let a consumer decide what is significant instead of guessing. - **Accumulate over a group.** The idea raised when this was filed: time a - batch of leaves with one pair of reads and divide, so the clamp is amortized + group of leaves with one pair of reads and divide, so the clamp is amortized across many proofs instead of applied to each. This is speculation — it - trades a per-test number for an average, it cannot attribute a slow proof, - and it interacts with concurrency, since `all` interleaves launches and a - group's wall time would then include siblings' work. Worth prototyping, - not worth assuming. + trades a per-test number for an average and cannot attribute a slow proof. + When this was filed the objection was concurrency: `all` interleaved + launches, so a group's wall time included siblings' work. The sequential + plan in [share-browser-console-runner](share-browser-console-runner.md) + retires that objection — one leaf's whole chain finishes before the next + starts — but replaces it with a smaller one: a group's span now also + carries the between-leaves overhead (the per-report yield, enumeration), + so the divided average still is not the leaves' own time. Worth + prototyping under the sequential traversal, not worth assuming. - **Cross-origin isolation.** Serving the eventual application root with `COOP: same-origin` and `COEP: require-corp` buys Chromium's 5 µs clock and is a header change in the shared controller, not a design change. It does @@ -88,8 +93,11 @@ before changing the measurement. and WebKit from inside the runner, and record the figures here. - [ ] Decide whether the report carries the resolution, and whether a row below it renders a duration at all. -- [ ] Prototype accumulated timing over a group of leaves and check what it - costs in attribution and what concurrency does to it. +- [ ] Prototype accumulated timing over a group of leaves under the + sequential traversal, and check what it costs in attribution and how + much between-leaves overhead (report yields, enumeration) lands inside + the group's span — the concurrency half of this question is gone with + the sequential plan. - [ ] Check whether cross-origin isolation is worth the headers in the shared controller. From 2e68cc944cbd50169fcb9b8a78353bca1495df3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:33:36 +0000 Subject: [PATCH 339/370] docs: say what CI checks about the package, not what it used to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fjs/fsc/README.md` claimed CI asserts both halves of the exclusion — that the tarball carries no `private.d.ts` and that what it does carry type-checks. The first half stopped being true when the packed-listing assertion was removed one commit earlier, and the sentence was left behind. It now says what holds: the type-check covers the consequence, and losing the negation is not caught, because the private declarations come back and every reference to them resolves. The reasoning for accepting that gap is one link away rather than restated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/fsc/README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/fjs/fsc/README.md b/fjs/fsc/README.md index 8e2fd37aa..b348953d1 100644 --- a/fjs/fsc/README.md +++ b/fjs/fsc/README.md @@ -198,10 +198,17 @@ its TypeScript alias was module-private, and a former export may become `_` when it only ever described an implementation detail. No generated `private.d.ts` ships: `package.json`'s `files` excludes them with -a `!**/private.d.ts` negation, and CI asserts both halves of that — the tarball -carries none, and every declaration it does carry type-checks as an outside -consumer installs it, so a public declaration that came to depend on a private -module is a red build rather than a broken package. +a `!**/private.d.ts` negation. + +What CI checks is the consequence, not the exclusion. Every declaration the +package does carry is type-checked as an outside consumer installs it, so a +public declaration that came to depend on a private module is a red build +rather than a broken package. Losing the negation itself is *not* caught: the +private declarations come back, every reference to them resolves, and that job +stays green. It is one line, and losing it is a visible diff in review — see +[`../ci/todo/f-mjs-package-support.md`](../ci/todo/f-mjs-package-support.md) +for why an assertion over the packed listing was written for that and then +removed. The `_` contract is permanent and independent of that. `_` helpers retained in `types.ts` by the public declaration closure, and exported `_` constants, keep From f2d5ea6fbe8970554c46ff5591b7d18816e1e9d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:36:53 +0000 Subject: [PATCH 340/370] changelog: trim the entry to the documented length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `changelog/README.md` asks for about three wrapped lines, ~250 characters. The entry was five lines and 370, and the excess was rationale — why the files were private, and what CI checks about the package — which the same guidance sends to the pull request description or the relevant README. Both are already there. What is left is the user-visible change: the files are gone, and depending on them was never supported. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- changelog/unreleased/1771.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/changelog/unreleased/1771.md b/changelog/unreleased/1771.md index 0a59abb8e..d848b2091 100644 --- a/changelog/unreleased/1771.md +++ b/changelog/unreleased/1771.md @@ -1,5 +1,3 @@ - Generated `private.d.ts` files are no longer published: `package.json`'s `files` excludes them, dropping 16 files from the package. They were - implementation-private by contract — `types.ts` may not depend on - `private.ts`, so no public declaration needed one — and the package's own - declarations are type-checked in CI as an outside consumer installs them. + implementation-private by contract, so nothing public depended on them. From f71f6fe2eb027461322656856100481461a371e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:39:24 +0000 Subject: [PATCH 341/370] todo: the re-export test runs post-move; cancellation checks precede children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups on the last round's fixes. The per-name re-export test is applied to effects/node as it stands after the move: errorExit's call to error moves to the console module with its body, so error is not kept by errorExit's keeping and joins the hard cutovers. The cancellation check point is before each leaf invocation, returned child and next sibling alike — a cancel landing during a parent's awaited report keeps its unstarted children unstarted; the un-interruptible unit is one leaf's own test and report. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/node-module-layering.md | 23 +++++++++++-------- .../todo/browser-test-controls.md | 14 ++++++----- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index 0be8b0cff..389b2d3ae 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -233,16 +233,19 @@ Judgement calls worth deciding explicitly rather than by accident: types — `NodeOp` unions `Read`, `Write` and `Test`, and `NodeProgramOptions` names `WriteConsoles` and `TestContext` — so those names stay re-exported by the same argument as `Sandbox` and `All`. The - test reaches the helpers one name at a time, and the surviving *code* - counts as much as the declarations: `exitStep` stays — it is the node - program's exit-code policy, consumed repo-wide — and it calls `errorExit`, - whose body calls `error`, so those two stay re-exported too. The names - nothing surviving touches — `log`, `readLine`, the `test` combinator — are - the dead couplings: their consumers are exactly the ones the moves exist - to decouple, so they move as hard cutovers, every importer updated in the - same PR, no re-export left behind. Draw the exact split at move time by - this test — grep what the surviving `effects/node` declarations *and - function bodies* reference — and note + test reaches the helpers one name at a time, the surviving *code* counts + as much as the declarations, and it is applied to the module **as it + stands after the move**: `exitStep` stays — it is the node program's + exit-code policy, consumed repo-wide — and it calls `errorExit`, so + `errorExit` stays re-exported. `errorExit`'s own call to `error` moves to + the console module with its body, and nothing that remains in + `effects/node` references `error` after that — so `error` is *not* kept + by `errorExit`'s keeping, and joins `log`, `readLine` and the `test` + combinator as the dead couplings: their consumers are exactly the ones + the moves exist to decouple, so they move as hard cutovers, every + importer updated in the same PR, no re-export left behind. Draw the exact + split at move time by this test — grep what the post-move `effects/node` + declarations *and function bodies* reference — and note that the decoupling each move exists for is enforced by its own step's check (`fjs/text/sgr` no longer importing `effects/node`), which a type re-export for node-side callers does not weaken. diff --git a/fjs/emergent_testing/todo/browser-test-controls.md b/fjs/emergent_testing/todo/browser-test-controls.md index 2300477c9..7fb648504 100644 --- a/fjs/emergent_testing/todo/browser-test-controls.md +++ b/fjs/emergent_testing/todo/browser-test-controls.md @@ -40,9 +40,11 @@ cannot always be interrupted; cancellation should be cooperative at module and leaf boundaries and document that limitation. (When this was filed the page ran batches, and the batch boundary was a natural check point; the sequential plan in [share-browser-console-runner](share-browser-console-runner.md) -removes batching, so the boundary that remains is between one leaf's whole -chain — test, report, children — and the next, which is finer-grained than -the batch boundary was.) +removes batching, so the check point is before each leaf invocation — the +next *sibling* and each returned *child* alike, since a cancel that lands +during a parent's awaited report must keep its unstarted children unstarted, +per the requirement above. The un-interruptible unit is one leaf's own test +and report, which is finer-grained than the batch was.) The final cancelled result needs a serializable status distinct from `failed` and `infrastructure-error`. Decide whether cancellation dispatches the existing @@ -60,9 +62,9 @@ module or a default query parameter. - [ ] Add a `Cancel` button and implement the inverse enabled/disabled states for `Run` and `Cancel`. - [ ] Add a per-run cancellation token or equivalent identity checked during - loading, at each sequential leaf boundary (the between-batches check - this task once named — gone with batching, per the note above), and - before every UI/global/event publication. + loading, before each leaf invocation — sibling and returned child alike + (the between-batches check this task once named — gone with batching, + per the note above), and before every UI/global/event publication. - [ ] Define the serializable cancelled report and completion-event behavior. - [x] Prove initial idle behavior and `Run`'s state transitions across loading, running, and both terminal outcomes; cancellation-related From d679b3750905d39ab5de9b69b79d27f9bf717b9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:40:50 +0000 Subject: [PATCH 342/370] spec/datajs: whitespace by token merging, round-trip digit selection Three review findings. The whitespace rule said space is required only between adjacent word tokens, but a number is not a word token, so a normalized root of 1 gave `export default1;` - which JavaScript rejects, `default1` lexing as one identifier. Verified with node --check, along with `export default-1;`, `export default[1];` and `export default"a";`, which genuinely need no space. The rule is now stated as: wherever omitting it would merge two tokens. The summary listed -0 among values JSON cannot spell. JSON spells it and JSON.parse preserves the sign; it is JSON.stringify that writes 0. The leaf table and the reverse-conversion list already had this right, so the summary was the odd one out. It now names -0 as its own case. Digit selection said `s x 10^(n-k)` is exactly the value, which for 0.1 selects the full binary expansion 0.1000000000000000055511151231257827 rather than 0.1, contradicting the sentence after it. The criterion is round-trip conversion back to the same Number, with ECMAScript's closest-then-even tie-break. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/datajs/README.md | 40 ++++++++++++++++++------- spec/datajs/todo/conformance-vectors.md | 5 +++- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/spec/datajs/README.md b/spec/datajs/README.md index 94fbb7861..ead5e3f33 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -4,12 +4,16 @@ DataJS is JSON with two extensions, and no other additions: 1. a value may be **shared**, so a document denotes a directed acyclic graph where JSON denotes a tree; -2. the leaf set gains the JavaScript values JSON cannot spell — `undefined`, - `bigint`, `NaN`, `Infinity`, `-Infinity` and `-0`. +2. the leaf set gains the JavaScript values JSON cannot carry — + `undefined`, `bigint`, `NaN`, `Infinity` and `-Infinity`. The first is the reason the format exists; the second is what it costs to round-trip a JavaScript value honestly. +`-0` is neither, and worth naming separately: JSON syntax spells it and +`JSON.parse` preserves the sign, but `JSON.stringify` writes it as `0`, so it +survives DataJS and not a JSON round trip. + A document is a JavaScript module: `const` statements naming values, then one `export default` naming the value the document denotes. @@ -73,9 +77,16 @@ Every other character JavaScript treats as whitespace or a line terminator is byte order mark, wherever they appear outside a string literal. Accepting them would import a taxonomy no implementer of a data format should have to know. -Whitespace is *required* only between two adjacent word tokens — `const a`, -`export default x`. Everywhere else it is optional, so every document has a -one-line spelling. +Whitespace is *required* exactly where leaving it out would merge two tokens +into one: between `const` and a name, between `export` and `default`, and +between `default` and any value beginning with an identifier character — a +name, `true`, `false`, `null`, `undefined`, `NaN`, `Infinity`, a number, or a +bigint. `export default1;` is not this format minus a space; it is a +JavaScript syntax error, because `default1` lexes as a single identifier. + +Everywhere else whitespace is optional, so every document has a one-line +spelling: `export default-1;`, `export default[1];` and `export default"a";` +all need no space. A document is UTF-8. It has no BOM. @@ -331,8 +342,10 @@ Normalized form is one specific serializer, chosen so that a value has need not produce it — but an implementation that claims to produce normalized DataJS must produce these bytes. -**Layout.** One line. Whitespace appears only where two word tokens would -otherwise merge (`const a`, `export default x`). No indentation, no trailing +**Layout.** One line. A single space appears exactly where the tokens would +otherwise merge, as defined under [Whitespace](#whitespace) — after `const`, +between `export` and `default`, and after `default` when the value begins with +an identifier character. Nowhere else: no indentation, and no trailing newline. **Which values become consts.** A value is hoisted into a `const` if and only @@ -362,10 +375,15 @@ because host formatters disagree on exactly these cases: JavaScript writes - A negative number is `-` followed by the spelling of its magnitude. `-0` is the one departure from `ToString`, which spells it `0`; normalized DataJS spells it `-0`. -- Otherwise pick integers `s`, `k`, `n`, with `k` as small as possible, such - that `10^(k-1) ≤ s < 10^k` and `s × 10^(n-k)` is exactly the value — `s` is - the shortest digit string that reads back as this number, `k` its length, - and `n` the position of the decimal point. Then: +- Otherwise pick integers `s`, `k`, `n` with `10^(k-1) ≤ s < 10^k` such that + `s × 10^(n-k)` **converts back to exactly this Number**, choosing `k` as + small as possible; `s` is that shortest digit string, `k` its length, and + `n` the position of the decimal point. Round-trip conversion decides this, + not exact real-number equality: `0.1` is the Number nearest one tenth, whose + exact value is `0.1000000000000000055511151231257827…`, and its spelling is + `0.1` because those digits convert back to it. Where several `s` of that + length qualify, take the one whose `s × 10^(n-k)` is closest to the Number's + exact value; if two are equally close, take the even `s`. Then: - `k ≤ n ≤ 21` — the `k` digits, then `n − k` zeros: `100`; - `0 < n ≤ 21` — the first `n` digits, `.`, the remaining `k − n`: `1.5`; - `−6 < n ≤ 0` — `0.`, then `−n` zeros, then the `k` digits: `0.000001`; diff --git a/spec/datajs/todo/conformance-vectors.md b/spec/datajs/todo/conformance-vectors.md index c5823e666..76e7d7653 100644 --- a/spec/datajs/todo/conformance-vectors.md +++ b/spec/datajs/todo/conformance-vectors.md @@ -48,7 +48,10 @@ A machine-readable corpus with three parts: number thresholds explicitly — `1e20`, `1e21`, `1e-6`, `1e-7`, `5e-324`, `1.7976931348623157e308` — since that is where a host's own formatter diverges, and pin `root=[p,p]` with `p=[c]` so the hoisting count - is occurrences rather than paths. + is occurrences rather than paths. Include a normalized root that is a bare + number and a bare bigint, so `export default 1;` cannot regress to + `export default1;` — which JavaScript rejects, `default1` being one + identifier. The corpus is data, not code, so it can be read by an implementation in any language. Store it as DataJS once `fjs/media/datajs` can read it; until then From f40d23ed033e2f36b6b9059adbe2d8afdaad042c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:45:41 +0000 Subject: [PATCH 343/370] todo: prove the run-failure guard in 7b, through a minimal seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task shipped 7b with a branch known to be untested — a page stuck in running forever if the guard is wrong — and deferred the proof to step 8's layout split. Superseded: 7b carries the seam at its smallest, the page's run core taking its interpreter as an argument and exported for proofs from the page's own module, so one failing operation drives the infrastructure-error report, mutation-tested like 7a's contract. The published entry point is unchanged, which is what the rejected widen-the-API alternative got wrong; step 8 absorbs the seam rather than creating it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/share-browser-console-runner.md | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 277ecbc83..f7dc2be20 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -803,18 +803,25 @@ are shared. and why even measured-correct it could not fix the reporting burst, is the pitfall catalog above (items 1, 2, 4, 11, 12). - [ ] Prove `runBrowserProofs`'s `infrastructure-error` branch — the run's - own failure, as opposed to any proof's — **at step 8, not step 7b.** - The order is forced, and worth stating so 7b is not directed to do the - impossible: the guard itself must land with 7b, because a page must - never stay in `running`, but neither half of it (an operation reporting - through the error channel, or one the interpreter cannot dispatch, - which rejects) is reachable through the public entry point — the - reverted #1759 proved that by mutation, removing the guard stayed - green. So 7b lands the guard and records it unproven, and step 8's - split of the page into `module.f.mjs`/`module.mjs` is the seam that - makes it reachable and closes this task. Widening the public API just - to reach the branch was considered and rejected: testing a thing by - deforming it. + own failure, as opposed to any proof's — **in step 7b, with the + minimal seam that makes it reachable.** Neither half of the branch (an + operation reporting through the error channel, or one the interpreter + cannot dispatch, which rejects) is reachable through the public entry + point — the reverted #1759 proved that by mutation: removing the guard + stayed green. An earlier version of this task concluded "land the + guard in 7b, record it unproven, prove it at step 8's + `module.f.mjs`/`module.mjs` split" — superseded, because that ships a + branch known to be untested whose failure mode is a page stuck in + `running` forever, exactly the class of hazard catalog item 11 exists + for. 7b instead carries the seam itself, at its smallest: the page's + run core takes its interpreter (or reporter) as an argument and is + exported for proofs from the page's own module, so a proof drives one + failing operation through it and watches the `infrastructure-error` + report land — mutation-tested like 7a's contract: remove the guard, + watch it fail. This is a testing seam, not a public-API widening — the + page's published entry point is unchanged, which is what the rejected + "widen the API to reach the branch" alternative got wrong. Step 8's + full layout split then absorbs the seam rather than creating it. ### Related From e8da30939a2182951dc23deb15eba86d0ad9c58b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:46:03 +0000 Subject: [PATCH 344/370] spec/datajs: spell positive zero before selecting digits Review finding: the restated ToString requires 10^(k-1) <= s < 10^k, which forces s >= 1, so the algorithm had no answer for positive zero. ECMA-262 returns "0" in an early step; the -0 departure was already covered but +0 was not. Confirmed String(0) is "0" and String(-0) is "0", which is why -0 needs its own rule and 0 needs this one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/datajs/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spec/datajs/README.md b/spec/datajs/README.md index ead5e3f33..c1980b421 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -372,6 +372,8 @@ because host formatters disagree on exactly these cases: JavaScript writes `1e20` as `100000000000000000000` where Python writes `1e+20`. - `NaN` is `NaN`; the infinities are `Infinity` and `-Infinity`. +- Positive zero is `0`. It is spelled directly, before the digit selection + below, which requires `s ≥ 1` and so has no answer for it. - A negative number is `-` followed by the spelling of its magnitude. `-0` is the one departure from `ToString`, which spells it `0`; normalized DataJS spells it `-0`. From c46383e00988e8aa34d8a1d5c97ecf3d68637f8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:47:41 +0000 Subject: [PATCH 345/370] rtti: constant-time declared-member lookup, and pin the gate by count Two things, one reported by each reviewer. The gate asked `declared.some(...)` per key of the value, so a large closed container was quadratic before any recursion: a 10 000-member tuple against a same-shaped value with a bad first member took 502ms, a comparable struct 251ms. The membership test is now built once per schema from a `Set` and asked per key: 7ms and 4ms. And the blow-up the gate exists to prevent was pinned only by error-path attribution, which a regression could preserve while walking a shared operand once per arm. `arityUnionVisitsEachOperandOnce` counts instead: an `or` of two arities whose arms share an operand, with the operand's thunk tallying its visits. 19 visits at depth 16 here against 131 071 for the unbounded readers, and it fails fast rather than hanging if the bound regresses. The README's DJS section also names the class the shape-before-reads order gives up on -- a getter that adds an undeclared member during the walk, which the deleted post-walk check used to catch. It is the same open family as the `length` example, which no amount of re-asking closes. --- fjs/rtti/README.md | 9 +++++++++ fjs/rtti/common/module.f.mjs | 20 +++++++++++++++++-- fjs/rtti/parse/module.f.mjs | 7 +++++-- fjs/rtti/validate/module.f.mjs | 7 +++++-- fjs/rtti/validate/proof.f.mjs | 35 ++++++++++++++++++++++++++++++++-- 5 files changed, 70 insertions(+), 8 deletions(-) diff --git a/fjs/rtti/README.md b/fjs/rtti/README.md index 88c550884..6cff4cb9c 100644 --- a/fjs/rtti/README.md +++ b/fjs/rtti/README.md @@ -94,6 +94,15 @@ is an action*, and the readers do not defend against it. Concretely: before the members are read and can decide what they are: a proxy over `['bad']` whose `length` getter sets index 0 to `1` is **accepted** against `[number]` by `parse` and `validate`, while the data form rejects it. +- **The same is true of the members a container has at all.** The shape is + settled before the members are read, so a read that *adds* a member is not + seen: an object whose `a` getter installs an undeclared `b` is **accepted** + against `{ a: number }` by `parse` and `validate`, while the data form + rejects it. An earlier revision re-asked the shape after the reads and + caught that one; the check was dropped rather than kept, because it closed + one arrangement out of an open set — the `length` example above defeats any + amount of re-asking, since it decides the members *before* anyone reads + them. - **So the three readers can disagree on such a value.** The agreement the tables in `validate/proof.f.mjs` pin — and that `host.proof.mjs` holds them to — is a promise about values whose reads are side-effect-free, which is diff --git a/fjs/rtti/common/module.f.mjs b/fjs/rtti/common/module.f.mjs index 510f5808a..7bc188202 100644 --- a/fjs/rtti/common/module.f.mjs +++ b/fjs/rtti/common/module.f.mjs @@ -347,13 +347,18 @@ export const undeclaredMembers = (declared, value) => { * still has to be materialized — JavaScript exposes no lazy own-key walk — * but the values are not read and the scan stops at the first hit. * + * `declared` is a **membership test**, not a list, and the caller builds it + * once per schema: asked per key, a linear scan of the declared names makes + * the gate quadratic in a large container — a 10 000-member tuple spent + * 0.5s here against a same-shaped value, for a question that is O(1) a key. + * * `undeclaredMembers` stays for the `rest` readers, which need the pairs. * - * @type {(declared: readonly string[], value: ReadonlyArray | StringMap) => boolean} + * @type {(declared: (k: string) => boolean, value: ReadonlyArray | StringMap) => boolean} */ export const hasUndeclaredMember = (declared, value) => { /** @type {(k: string) => boolean} */ - const undeclared = k => !declared.some(d => d === k) + const undeclared = k => !declared(k) if (!commonIsArray(value)) { return Object.keys(value).some(undeclared) } @@ -361,6 +366,17 @@ export const hasUndeclaredMember = (declared, value) => { || Object.keys(value).some(k => arrayIndex(k) === undefined && undeclared(k)) } +/** + * {@link hasUndeclaredMember}'s membership test over a schema's declared + * names — built once per schema, so each key costs one lookup. + * + * @type {(declared: readonly string[]) => (k: string) => boolean} + */ +export const declaredTest = declared => { + const names = new Set(declared) + return k => names.has(k) +} + /** * Whether `rtti` admits **absence** with `visited` already ruled out — the * recursive half of {@link admitsAbsence}, carrying the thunks on the current diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 7d3dfc878..951334d41 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -59,7 +59,9 @@ import { ok } from '../../types/result/module.f.mjs' import { absentMember, constPrimitiveValidate, + declaredTest, eachEntry, + hasUndeclaredMember, isArray, isObject, orVisit, @@ -67,7 +69,6 @@ import { primitive0Validate, structSchemaEntries, tupleSchemaEntries, - hasUndeclaredMember, undeclaredMembers, verror, visit, @@ -337,6 +338,8 @@ const constContainerParse = // Depend on `rtti` alone, so they are computed once per schema. const rttiEntries = schemaEntries(rtti) const declared = rttiEntries.map(([k]) => k) + // One lookup per key at the gate, rather than a scan of `declared`. + const isDeclared = declaredTest(declared) return value => { if (!isContainer(value)) { return verror('unexpected value') @@ -362,7 +365,7 @@ const constContainerParse = acc => acc, ) if (a[0] === 'error') { return a } - if (hasUndeclaredMember(declared, value)) { + if (hasUndeclaredMember(isDeclared, value)) { return verror('unexpected value') } const r = eachEntry( diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index be469367d..d231b271a 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -87,8 +87,10 @@ import { absentMember, consPresence, constPrimitiveValidate, + declaredTest, eachEntry, emptyPresence, + hasUndeclaredMember, isArray, isObject, orVisit, @@ -96,7 +98,6 @@ import { primitive0Validate, structSchemaEntries, tupleSchemaEntries, - hasUndeclaredMember, undeclaredMembers, verror, visit, @@ -212,6 +213,8 @@ const constContainerValidate = // than once per validated value. const rttiEntries = schemaEntries(rtti) const declared = rttiEntries.map(([k]) => k) + // One lookup per key at the gate, rather than a scan of `declared`. + const isDeclared = declaredTest(declared) return value => { if (!isContainer(value)) { return verror('unexpected value') @@ -266,7 +269,7 @@ const constContainerValidate = acc => acc, ) if (a[0] === 'error') { return a } - if (hasUndeclaredMember(declared, value)) { + if (hasUndeclaredMember(isDeclared, value)) { return verror('unexpected value') } const r = eachEntry( diff --git a/fjs/rtti/validate/proof.f.mjs b/fjs/rtti/validate/proof.f.mjs index 4c69b207e..d964587b6 100644 --- a/fjs/rtti/validate/proof.f.mjs +++ b/fjs/rtti/validate/proof.f.mjs @@ -866,8 +866,10 @@ export const proof = { // than the first bad member, and they report it identically. That // precedence is what lets a container be bounded before recursing, which // an `or` of two arities needs — see the comment on the gate in - // `./module.f.mjs`. Acceptance is untouched: the same check is re-asked - // after the reads, so the gate only ever rejects earlier. + // `./module.f.mjs`. Acceptance is untouched: a shape the gate rejects is + // one no walk could have accepted, which the differential against the + // unbounded readers confirms. What the gate is *for* is counted by + // {@link arityUnionVisitsEachOperandOnce}, not by these paths. structuralMismatchIsAnsweredFirst: () => { const t = /** @type {const} */ ([42]) // too long *and* wrong at index 0 — the length is what answers @@ -893,6 +895,35 @@ export const proof = { // and a value that fits is read as before for (const read of [v, p, d]) { assertOk(read(t)([42])) } }, + // Every row above is about error **attribution**, and a regression that + // walked a shared operand once per arm while reporting the same paths + // would pass them all. So this one counts instead: an `or` of two + // arities whose arms share an operand, with the operand's thunk tallying + // how often it is visited. + // + // Linear here, exponential without the bound — measured against the + // unbounded readers at the same depths: 31 visits at depth 4, 511 at 8, + // 131 071 at 16, against 7, 11 and 19 here. The bound is generous enough + // to survive a benign refactor and far below 2^depth either way. + arityUnionVisitsEachOperandOnce: () => { + /** + * @typedef {() => readonly ['or', typeof number, typeof string, _Dot]} _Exp + * @typedef {() => readonly ['or', readonly ['.', _Exp, typeof string], readonly ['.', _Exp, typeof string, readonly ['|()', _Exp]]]} _Dot + */ + let visits = 0 + /** @type {_Exp} */ + const exp = () => { visits += 1; return ['or', number, string, dot] } + /** @type {_Dot} */ + const dot = () => ['or', ['.', exp, string], ['.', exp, string, ['|()', exp]]] + /** @type {(n: number) => Unknown} */ + const chain = n => n === 0 ? ['nope'] : ['.', chain(n - 1), 'b'] + const depth = 16 + for (const read of [v, p]) { + visits = 0 + assertError(read(exp)(chain(depth))) + assert(visits <= 3 * depth, 'the shared operand is walked once per level, not once per arm') + } + }, // The walk is bounded by what the value and its prototypes carry rather // than by `length`: a sparse array as long as the index space allows // answers at once, where materializing the range exhausted memory first. From f7cc1dbb248321f6cff44ef90ef62180eeb38b94 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:48:18 +0000 Subject: [PATCH 346/370] changelog: trim the entry to the documented length The precedence detail belongs in the pull request description and the gate's own comment, not the release note. Reported by Codex. --- changelog/unreleased/1766.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/changelog/unreleased/1766.md b/changelog/unreleased/1766.md index ae6c89ccf..436e3eff4 100644 --- a/changelog/unreleased/1766.md +++ b/changelog/unreleased/1766.md @@ -1,5 +1,3 @@ -- `rtti`: `validate` and `parse` answer a closed tuple's or struct's structure - before reading any member — the length bound, then an illegal absence, then - an undeclared member — so an `or` of two arities no longer walks shared - operands once per arm. Acceptance is unchanged; those three errors now win, - in that order, over a member's. +- `rtti`: `validate` and `parse` answer a closed tuple's or struct's shape + before reading any member, so an `or` of two arities no longer walks shared + operands once per arm. A shape error now wins over a member's. From ee9919264e3f032e8e5357a1f02e7f987dc2affc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:50:12 +0000 Subject: [PATCH 347/370] todo: the browser start handler yields before the proof runs Appending a DOM node does not paint it: without a macrotask yield after rendering the pending row, a proof that runs synchronously for seconds would run and settle the row before the first paint, and the running test this issue exists to show would never be visible. The start handler now awaits one macrotask, exactly as the report handler does after a result; the yield lands before the sandbox's adjacent clock reads, so the duration constraint is unaffected; the task proves the pending row observable before the proof body starts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/report-before-running.md | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/fjs/emergent_testing/todo/report-before-running.md b/fjs/emergent_testing/todo/report-before-running.md index 364f65d7b..e0aa87f9c 100644 --- a/fjs/emergent_testing/todo/report-before-running.md +++ b/fjs/emergent_testing/todo/report-before-running.md @@ -47,8 +47,15 @@ before the leaf is sandboxed, and let each host decide what to do with it: that; in the common case the result still directly follows its own start, and the repeated name is what keeps the pair legible when something intervenes. -- **The browser page** renders a row in a pending state and settles it in place, - which is the same list it renders now with one more state per row. +- **The browser page** renders a row in a pending state and settles it in + place — the same list it renders now with one more state per row — **and + its start handler awaits one macrotask after rendering the pending row**, + exactly as its `report` handler does after a result. Appending a DOM node + does not paint it: without the yield, a proof that runs synchronously for + seconds would run and settle the row before the first paint, and the + running test this issue exists to show would never be visible. The yield + sits before the sandboxed clock reads, so the reported duration is + untouched (the constraint below). - **A result type** may not need to change at all: a start is an event, not a result. Whether the reporter grows a sibling operation or its existing one gains a status is part of the design. @@ -64,7 +71,9 @@ still the argument for settling it in the shared core rather than twice. ### Constraints - A start event must not cost a `sandbox` call or a clock read of its own: the - duration reported is still the sandboxed one. + duration reported is still the sandboxed one. The browser start handler's + macrotask yield (above) is compatible: it lands before the sandbox's + adjacent clock reads, so it delays the start, not the measurement. - The runner's scheduling is not this issue's to change, in either direction. When this was written that meant "concurrency stays"; the sequential plan in [share-browser-console-runner](share-browser-console-runner.md) has since @@ -91,7 +100,10 @@ still the argument for settling it in the shared core rather than twice. legible — but a leaf's *own* output can still land between its start and its result, so the proof includes a proof that writes to the terminal mid-test and shows both records intact around it. -- [ ] Render a pending row in the browser page and settle it in place. +- [ ] Render a pending row in the browser page, await one macrotask in the + start handler, and settle the row in place — and prove the pending row + is observable before the proof body starts (a proof whose body reads + the DOM, or blocks long enough that an unpainted row would be caught). - [ ] Prove that a run killed mid-test leaves the running test's name behind. ### Related From 03ebbf7f99abe9c769daa838f0e8f726be77df7a Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 18:51:15 -0700 Subject: [PATCH 348/370] todo: clarify package-check TypeScript PATH --- fjs/ci/todo/typescript-ci-tool.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 5e80e7ce6..46d51a408 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -23,6 +23,7 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Add a pinned TypeScript version to the CI tool configuration. - [ ] Make the packed-package check read its compiler pin from that CI configuration instead of `package.json` so removing `devDependencies.typescript` does not remove `package-check`; update the related proofs for the new pin source. +- [ ] Provision the packed-package check's pinned TypeScript on `PATH` (for example by installing it globally or explicitly exporting its binary directory) before changing that check from `npx tsc` to `tsc`; verify it cannot fall back to an unrelated ambient compiler. - [ ] Provision that TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). - [ ] Add the pinned TypeScript package to the generated Node 26 Nix environment (`nodeNixJobs`) so the canonical development shell provides `tsc`; update its proofs/generated-flake expectations. - [ ] Provision the pinned TypeScript in the npm publishing workflow so `prepack` uses the intended compiler during `npm publish`. @@ -33,5 +34,5 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Update the Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks have the pinned TypeScript on `PATH` without relying on the root devDependency. - [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily and their frozen-lock installs still succeed. - [ ] Verify the generated Node 26 Nix shell provides the pinned `tsc` and can run the canonical type-check/package commands without a local TypeScript devDependency. -- [ ] Verify `package-check` remains generated and validates the packed declarations with the CI-configured compiler pin. +- [ ] Verify `package-check` remains generated and validates the packed declarations with the CI-configured compiler pin and the intended `tsc` on `PATH`. - [ ] Verify `tsc`, `npm test`, `npm pack`, and the npm publish path work in every environment that is documented or responsible for those checks. From 9ff8ad2adee326b13cbbca577aacece4ca12ea3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:55:21 +0000 Subject: [PATCH 349/370] todo: the yields and the guard get ordering proofs, one per route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three proof requirements were coincidence-shaped by the catalog's own standard. The run-failure guard's proof now drives each failure route separately — error-channel answer and interpreter rejection — with one mutation per half of the guard. The browser start-yield proof is an ordering sentinel (a macrotask enqueued before the handler must fire before the proof body), since a DOM-reading body sees the same DOM with the yield deleted and a blocking body cannot see paint from inside its own task. 7b's per-result await — the port's only boundary against the single-task freeze — gets the same sentinel proof, because the fake- document page proof stays green without it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/report-before-running.md | 14 ++++++++++--- .../todo/share-browser-console-runner.md | 20 +++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/fjs/emergent_testing/todo/report-before-running.md b/fjs/emergent_testing/todo/report-before-running.md index e0aa87f9c..087f2b41e 100644 --- a/fjs/emergent_testing/todo/report-before-running.md +++ b/fjs/emergent_testing/todo/report-before-running.md @@ -101,9 +101,17 @@ still the argument for settling it in the shared core rather than twice. and its result, so the proof includes a proof that writes to the terminal mid-test and shows both records intact around it. - [ ] Render a pending row in the browser page, await one macrotask in the - start handler, and settle the row in place — and prove the pending row - is observable before the proof body starts (a proof whose body reads - the DOM, or blocks long enough that an unpainted row would be caught). + start handler, and settle the row in place — and prove the *yield*, + not the append. A proof body that reads the DOM proves nothing here: + the pending node is appended synchronously before the await, so the + DOM looks identical with the yield deleted, and a blocking body cannot + see from inside its own task whether the browser painted first — an + item-11 coincidence proof in either shape. The proof is an ordering + sentinel: a macrotask enqueued before the start handler runs must be + observed to fire before the proof body starts (or a real-browser + observation of the painted row, as the burst was measured), and the + mutation check is deleting the await and watching the sentinel land + after the body instead. - [ ] Prove that a run killed mid-test leaves the running test's name behind. ### Related diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index f7dc2be20..91b8c9952 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -466,6 +466,14 @@ and is reviewable without the next one. expectation, walking return values and counting: it supplies a `Reporter` whose `result` hands the record to its `report` operation, and a `report` handler that appends the row and awaits one macrotask. + That await is the port's only boundary against the single-task freeze + (catalog item 1), and the page proof's fake document cannot see + painting — every semantic assertion stays green with the await + deleted, the incidental-yield trap item 11 names. So the boundary gets + its own ordering proof: a macrotask enqueued before a result is + reported must be observed to fire before the next leaf runs, + mutation-checked by removing the await and watching the sentinel land + after the whole suite instead. Update `RunTotals`'s JSDoc in `types.ts` here too: it explains wall-clock-vs-summed-duration by leaves running concurrently, and under this step the gap is what the run does *between* leaves — per-report @@ -815,10 +823,14 @@ are shared. `running` forever, exactly the class of hazard catalog item 11 exists for. 7b instead carries the seam itself, at its smallest: the page's run core takes its interpreter (or reporter) as an argument and is - exported for proofs from the page's own module, so a proof drives one - failing operation through it and watches the `infrastructure-error` - report land — mutation-tested like 7a's contract: remove the guard, - watch it fail. This is a testing seam, not a public-API widening — the + exported for proofs from the page's own module, so proofs drive **each + failure route separately** — one case for an operation answering + through the error channel, one for an operation the interpreter cannot + dispatch, which rejects — and watch the `infrastructure-error` report + land from both. Two routes need two mutations: delete either half of + the guard alone and its case fails while the other stays green, or the + surviving half is masking an untested branch that can still leave the + page in `running` forever. This is a testing seam, not a public-API widening — the page's published entry point is unchanged, which is what the rejected "widen the API to reach the branch" alternative got wrong. Step 8's full layout split then absorbs the seam rather than creating it. From 7eee5700bdd425f6b8ccbc7edb6aa68ca53ee8b8 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 18:56:34 -0700 Subject: [PATCH 350/370] todo: prefer Nix before TypeScript PATH workarounds --- fjs/ci/todo/typescript-ci-tool.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 46d51a408..5d051e22c 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -17,16 +17,18 @@ Provision a pinned TypeScript version through the CI tool environment and remove Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job, its generated Node 26 Nix environment, packed-package check, and package publishing path do need the pinned compiler because they invoke `tsc` directly, provide the canonical development toolchain, install it for declaration validation, or invoke it through npm lifecycle scripts such as `prepack`. +Prefer migrating an environment to Nix before adding an npm/global-install or manual `PATH` workaround. If `package-check` or npm publishing runs inside a Nix environment that provides the pinned TypeScript, `tsc` is already on `PATH` and no separate TypeScript installation is needed there. + Local development must continue to support `tsc`, `npm test`, and `npm pack`: outside an environment that provides the compiler, developers install the pinned TypeScript globally so `tsc` is available on `PATH`. ### Tasks - [ ] Add a pinned TypeScript version to the CI tool configuration. - [ ] Make the packed-package check read its compiler pin from that CI configuration instead of `package.json` so removing `devDependencies.typescript` does not remove `package-check`; update the related proofs for the new pin source. -- [ ] Provision the packed-package check's pinned TypeScript on `PATH` (for example by installing it globally or explicitly exporting its binary directory) before changing that check from `npx tsc` to `tsc`; verify it cannot fall back to an unrelated ambient compiler. +- [ ] Prefer moving `package-check` to Nix first and provide the pinned TypeScript there. Only if it remains outside Nix, provision its pinned TypeScript on `PATH` explicitly before changing that check from `npx tsc` to `tsc`; verify it cannot fall back to an unrelated ambient compiler. - [ ] Provision that TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). - [ ] Add the pinned TypeScript package to the generated Node 26 Nix environment (`nodeNixJobs`) so the canonical development shell provides `tsc`; update its proofs/generated-flake expectations. -- [ ] Provision the pinned TypeScript in the npm publishing workflow so `prepack` uses the intended compiler during `npm publish`. +- [ ] Prefer moving npm publishing to Nix first and provide the pinned TypeScript there so `prepack` uses the intended compiler during `npm publish`. Only add a separate compiler-install step if publishing remains outside Nix. - [ ] Run `tsc` from `PATH` instead of relying on `npx tsc` / `node_modules/.bin/tsc`. - [ ] Remove `typescript` from the root `package.json` `devDependencies`, then run `npm run update` so `package-lock.json`, `deno.lock`, `bun.lock`, and generated CI files are all regenerated consistently. - [ ] Keep `@types/node` as a devDependency. From f6a120c840ae759f5e273bedba328c196ca7e7ed Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 18:57:57 -0700 Subject: [PATCH 351/370] todo: reference related Nix work --- fjs/ci/todo/typescript-ci-tool.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 5d051e22c..a435d92db 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -17,7 +17,7 @@ Provision a pinned TypeScript version through the CI tool environment and remove Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job, its generated Node 26 Nix environment, packed-package check, and package publishing path do need the pinned compiler because they invoke `tsc` directly, provide the canonical development toolchain, install it for declaration validation, or invoke it through npm lifecycle scripts such as `prepack`. -Prefer migrating an environment to Nix before adding an npm/global-install or manual `PATH` workaround. If `package-check` or npm publishing runs inside a Nix environment that provides the pinned TypeScript, `tsc` is already on `PATH` and no separate TypeScript installation is needed there. +Prefer migrating an environment to Nix before adding an npm/global-install or manual `PATH` workaround. The direct-Nix migration is tracked by [65Z-ci-nix](65z-ci-nix.md) and its concrete Node-job implementation [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). If `package-check` or npm publishing runs inside a Nix environment that provides the pinned TypeScript, `tsc` is already on `PATH` and no separate TypeScript installation is needed there. Local development must continue to support `tsc`, `npm test`, and `npm pack`: outside an environment that provides the compiler, developers install the pinned TypeScript globally so `tsc` is available on `PATH`. @@ -25,10 +25,10 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Add a pinned TypeScript version to the CI tool configuration. - [ ] Make the packed-package check read its compiler pin from that CI configuration instead of `package.json` so removing `devDependencies.typescript` does not remove `package-check`; update the related proofs for the new pin source. -- [ ] Prefer moving `package-check` to Nix first and provide the pinned TypeScript there. Only if it remains outside Nix, provision its pinned TypeScript on `PATH` explicitly before changing that check from `npx tsc` to `tsc`; verify it cannot fall back to an unrelated ambient compiler. +- [ ] Prefer moving `package-check` to Nix first and provide the pinned TypeScript there, following the direct-Nix model in [65Z-ci-nix](65z-ci-nix.md). Only if it remains outside Nix, provision its pinned TypeScript on `PATH` explicitly before changing that check from `npx tsc` to `tsc`; verify it cannot fall back to an unrelated ambient compiler. - [ ] Provision that TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). -- [ ] Add the pinned TypeScript package to the generated Node 26 Nix environment (`nodeNixJobs`) so the canonical development shell provides `tsc`; update its proofs/generated-flake expectations. -- [ ] Prefer moving npm publishing to Nix first and provide the pinned TypeScript there so `prepack` uses the intended compiler during `npm publish`. Only add a separate compiler-install step if publishing remains outside Nix. +- [ ] Add the pinned TypeScript package to the generated Node 26 Nix environment (`nodeNixJobs`) so the canonical development shell provides `tsc`; update its proofs/generated-flake expectations. This extends the Node 26 migration tracked by [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). +- [ ] Prefer moving npm publishing to Nix first and provide the pinned TypeScript there so `prepack` uses the intended compiler during `npm publish`. Coordinate this with [668-ci-npm-publish-workflow](668-ci-npm-publish-workflow.md), which owns generation of the publish workflow. Only add a separate compiler-install step if publishing remains outside Nix. - [ ] Run `tsc` from `PATH` instead of relying on `npx tsc` / `node_modules/.bin/tsc`. - [ ] Remove `typescript` from the root `package.json` `devDependencies`, then run `npm run update` so `package-lock.json`, `deno.lock`, `bun.lock`, and generated CI files are all regenerated consistently. - [ ] Keep `@types/node` as a devDependency. @@ -38,3 +38,9 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Verify the generated Node 26 Nix shell provides the pinned `tsc` and can run the canonical type-check/package commands without a local TypeScript devDependency. - [ ] Verify `package-check` remains generated and validates the packed declarations with the CI-configured compiler pin and the intended `tsc` on `PATH`. - [ ] Verify `tsc`, `npm test`, `npm pack`, and the npm publish path work in every environment that is documented or responsible for those checks. + +### Related + +- [65Z-ci-nix](65z-ci-nix.md) — declarative per-job Nix architecture and direct CI execution. +- [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md) — concrete Node 22/24/26 Nix migration, including the generated Node 26 environment this task extends. +- [668-ci-npm-publish-workflow](668-ci-npm-publish-workflow.md) — generation of the npm publishing workflow; relevant if publishing moves to Nix before the TypeScript dependency is removed. From 41e63e8d7a62d36f28de53f91991b236acd7ebf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:59:00 +0000 Subject: [PATCH 352/370] rtti: drop the presence list; probe as the walks go The list was one entry per declared member and was built before the absence pass could reject anything, so a large schema paid for the whole schema on a value that the first member settles: a 500 000-position tuple against `[]` took 588ms. Now 0ms -- the absence pass carries nothing forward, so it stops at the first illegal omission having touched only the members before it. The reading walk asks `in` itself rather than being handed a recorded flag. That is one extra `HasProperty` per present member, on a value whose reads have no effect -- the assumption the readers are written under and stated in the README -- and it removes an allocation the short-circuit would have wasted. It also takes the walks back to main's shape, with the two structural gates in front of them. Acceptance unchanged: 0 differences over the 1550-pair differential against main. Every earlier measurement holds -- chains linear at depth 400, struct arms under 1ms at depth 20, the 10 000-member gate at 6ms. Reported by Codex on the pull request. --- fjs/rtti/parse/module.f.mjs | 24 +++++++++++------------- fjs/rtti/validate/module.f.mjs | 31 ++++++++++++++++++------------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/fjs/rtti/parse/module.f.mjs b/fjs/rtti/parse/module.f.mjs index 951334d41..20562e733 100644 --- a/fjs/rtti/parse/module.f.mjs +++ b/fjs/rtti/parse/module.f.mjs @@ -344,8 +344,8 @@ const constContainerParse = if (!isContainer(value)) { return verror('unexpected value') } - // The bound, presence, absence, the undeclared check, then - // the reads. See the comment on the same shape in + // The bound, absence, the undeclared check, then the reads. + // See the comment on the same shape in // `../validate/module.f.mjs`, including what settling the shape // first assumes of the value. // Cheapest structural question first, for the reason @@ -353,14 +353,12 @@ const constContainerParse = if (!fits(value, declared.length)) { return verror('unexpected value') } - const withPresence = rttiEntries.map(([k, t]) => - /** @type {readonly[string, readonly[typeof t, boolean]]} */ ([k, [t, k in value]])) - // Absence before any read, for the reason `../validate`'s - // copy of this comment gives: reaching an illegal absence - // through the reading walk restores the exponential. + // Absence before any read, and carrying nothing forward so it + // stops at the first illegal one — for the reasons + // `../validate`'s copy of this comment gives. const a = eachEntry( - withPresence, - (_k, [t, present]) => present ? ok(undefined) : absentMember(t), + rttiEntries, + (k, t) => k in value ? ok(undefined) : absentMember(t), undefined, acc => acc, ) @@ -369,10 +367,10 @@ const constContainerParse = return verror('unexpected value') } const r = eachEntry( - withPresence, - (k, [t, present]) => { - // Absence is settled above; this walk only records it. - if (!present) { return ok([]) } + rttiEntries, + (k, t) => { + // Absence is settled above, so this one is legal. + if (!(k in value)) { return ok([]) } const p = /** @type {any} */ (parse(t))(getItem(value, k)) return p[0] === 'error' ? p : ok([p[1]]) }, diff --git a/fjs/rtti/validate/module.f.mjs b/fjs/rtti/validate/module.f.mjs index d231b271a..c443dbc11 100644 --- a/fjs/rtti/validate/module.f.mjs +++ b/fjs/rtti/validate/module.f.mjs @@ -220,9 +220,9 @@ const constContainerValidate = return verror('unexpected value') } // The container's **shape** is settled before any member is - // read: it is bounded, presence is recorded, an illegal absence - // is rejected, an undeclared member is rejected — and only then - // are the members read, from the flags already recorded. + // read: it is bounded, an illegal absence is rejected, an + // undeclared member is rejected — and only then are the members + // read. // // That order is what makes an `or` of two arities linear // instead of 2^depth, which is the shape a schema uses to say a @@ -249,22 +249,27 @@ const constContainerValidate = // `Object.keys` on 500 000 keys is 185ms whether or not the // scan stops at the first. So a value one question answers // never pays for the ones after it, and the constant-time - // bound precedes even the presence list, which is one entry - // per declared member and so is the schema's size. + // bound precedes everything else. if (!fits(value, declared.length)) { return verror('unexpected value') } - const withPresence = rttiEntries.map(([k, v]) => - /** @type {readonly[string, readonly[typeof v, boolean]]} */ ([k, [v, k in value]])) // Reaching an illegal absence through the reading walk would // first recurse into the members that come before it, and those // are the operands the longer arm shares — so the two arms would // walk them once each at every level, which is the exponential // all over again. Measured on a chain of `['.', exp, index]` // with a leaf no arm accepts: 2.5s at depth 16 without this. + // + // The pass carries nothing forward, so it stops at the first + // illegal absence having touched only the members before it: a + // 500 000-position schema against `[]` answers at index 0. The + // reading walk asks `in` again rather than being handed a + // recorded flag — one `HasProperty` on a value whose reads have + // no effect, which is the assumption stated in `../README.md`, + // and cheaper than a list the short-circuit would waste. const a = eachEntry( - withPresence, - (_k, [v, present]) => present ? ok(undefined) : absentMember(v), + rttiEntries, + (k, v) => k in value ? ok(undefined) : absentMember(v), undefined, acc => acc, ) @@ -273,10 +278,10 @@ const constContainerValidate = return verror('unexpected value') } const r = eachEntry( - withPresence, - (k, [v, present]) => { - // Absence is settled above; this walk only records it. - if (!present) { return ok(false) } + rttiEntries, + (k, v) => { + // Absence is settled above, so this one is legal. + if (!(k in value)) { return ok(false) } const m = /** @type {any} */ (validate(v))(getItem(value, k)) return m[0] === 'error' ? m : ok(true) }, From d720fde29aad29954acb338cf44e0ba922972327 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 19:03:34 -0700 Subject: [PATCH 353/370] todo: preserve package-check isolation under Nix --- fjs/ci/todo/typescript-ci-tool.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index a435d92db..751b79e97 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -17,7 +17,7 @@ Provision a pinned TypeScript version through the CI tool environment and remove Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job, its generated Node 26 Nix environment, packed-package check, and package publishing path do need the pinned compiler because they invoke `tsc` directly, provide the canonical development toolchain, install it for declaration validation, or invoke it through npm lifecycle scripts such as `prepack`. -Prefer migrating an environment to Nix before adding an npm/global-install or manual `PATH` workaround. The direct-Nix migration is tracked by [65Z-ci-nix](65z-ci-nix.md) and its concrete Node-job implementation [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). If `package-check` or npm publishing runs inside a Nix environment that provides the pinned TypeScript, `tsc` is already on `PATH` and no separate TypeScript installation is needed there. +Prefer migrating an environment to Nix before adding an npm/global-install or manual `PATH` workaround. The direct-Nix migration is tracked by [65Z-ci-nix](65z-ci-nix.md) and its concrete Node-job implementation [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). A Nix migration must preserve the environment's existing isolation guarantees; in particular, `package-check` must remain a checkout-free packed-package consumer rather than gaining access to repository sources or `tsconfig.json` merely so it can reach a relative flake. If Nix cannot provide the compiler while preserving that isolation, keep explicit pinned `PATH` provisioning for that job instead. Local development must continue to support `tsc`, `npm test`, and `npm pack`: outside an environment that provides the compiler, developers install the pinned TypeScript globally so `tsc` is available on `PATH`. @@ -25,7 +25,7 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Add a pinned TypeScript version to the CI tool configuration. - [ ] Make the packed-package check read its compiler pin from that CI configuration instead of `package.json` so removing `devDependencies.typescript` does not remove `package-check`; update the related proofs for the new pin source. -- [ ] Prefer moving `package-check` to Nix first and provide the pinned TypeScript there, following the direct-Nix model in [65Z-ci-nix](65z-ci-nix.md). Only if it remains outside Nix, provision its pinned TypeScript on `PATH` explicitly before changing that check from `npx tsc` to `tsc`; verify it cannot fall back to an unrelated ambient compiler. +- [ ] Preserve `package-check` isolation. Prefer Nix only if the pinned compiler can be supplied without checking out the repository or exposing repository `tsconfig.json`, sources, or `node_modules` to the packed-package consumer (for example, through an isolated Nix environment available independently of the checkout). Otherwise provision the pinned TypeScript explicitly on `PATH` and change the check from `npx tsc` to `tsc`; verify it cannot fall back to an unrelated ambient compiler. - [ ] Provision that TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). - [ ] Add the pinned TypeScript package to the generated Node 26 Nix environment (`nodeNixJobs`) so the canonical development shell provides `tsc`; update its proofs/generated-flake expectations. This extends the Node 26 migration tracked by [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). - [ ] Prefer moving npm publishing to Nix first and provide the pinned TypeScript there so `prepack` uses the intended compiler during `npm publish`. Coordinate this with [668-ci-npm-publish-workflow](668-ci-npm-publish-workflow.md), which owns generation of the publish workflow. Only add a separate compiler-install step if publishing remains outside Nix. @@ -36,7 +36,7 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Update the Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks have the pinned TypeScript on `PATH` without relying on the root devDependency. - [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily and their frozen-lock installs still succeed. - [ ] Verify the generated Node 26 Nix shell provides the pinned `tsc` and can run the canonical type-check/package commands without a local TypeScript devDependency. -- [ ] Verify `package-check` remains generated and validates the packed declarations with the CI-configured compiler pin and the intended `tsc` on `PATH`. +- [ ] Verify `package-check` remains checkout-free and validates only the packed declarations with the CI-configured compiler pin and intended `tsc` on `PATH`. - [ ] Verify `tsc`, `npm test`, `npm pack`, and the npm publish path work in every environment that is documented or responsible for those checks. ### Related From f2d4d7cf9b5de61fb432a44d02b64ed0f2add92e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 02:55:45 +0000 Subject: [PATCH 354/370] spec/datajs: formalize signed Infinity, widen the Status gap Review follow-ups from the approval. The value production used '-Infinity' as a terminal that no lexical rule could produce - the minus folding existed only in prose. There is now an `infinity ::= '-'? 'Infinity'` production, so the three signed tokens (number, bigint, infinity) carry the sign in the grammar and nothing else accepts a leading '-'. Status named only the ';' difference from shipped code. Ran fjs/djs and confirmed four more, now tabled there: consts are named c0 rather than _0; a repeated primitive is hoisted where the spec keeps primitives inline; keys sort lexicographically, so {"10":0,"9":0} where the observable JS order is "9" then "10"; and NaN and the infinities serialize as null while -0 becomes 0. All stage 4-6 work, but Status is where a reader looks for it. The JSON relationship now names both exclusion mechanisms the reviewer identified and I confirmed: object-shaped JSON is not valid JavaScript at module top level, since { opens a block, while scalar and array JSON is valid JavaScript that exports no default. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/datajs/README.md | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/spec/datajs/README.md b/spec/datajs/README.md index c1980b421..513e7d9a7 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -39,7 +39,19 @@ belongs to [FunctionalScript](../README.md), not here. **This document specifies a target, not the current implementation.** The FunctionalScript compiler in this repository does not accept DataJS today: it separates statements by newline, so it rejects the `;` this format requires. -The work that closes the gap is staged in +The `;` is the difference that stops a document parsing at all, but it is not +the only one. The shipped `fjs/djs` serializer also differs from +[normalized form](#normalized-form) in four ways, each of them stage 4–6 work +rather than a bug: + +| shipped `fjs/djs` | this specification | +| --- | --- | +| `const c0 = …` | `const _0=…` | +| hoists a repeated primitive into a const | primitives always inline | +| keys sorted lexicographically — `{"10":0,"9":0}` | array-index keys first in numeric order — `"9"` before `"10"` | +| `NaN`, `±Infinity` become `null`; `-0` becomes `0` | each round-trips exactly | + +The work that closes all of it is staged in [`todo/parser-serializer-restructure.md`](../../todo/parser-serializer-restructure.md). Note the two nearby uses of "DJS". [`spec/README.md`](../README.md) uses it for @@ -95,13 +107,15 @@ A document is UTF-8. It has no BOM. ```text punctuator ::= '{' | '}' | '[' | ']' | ':' | ',' | '=' | ';' word ::= 'const' | 'export' | 'default' - | 'true' | 'false' | 'null' | 'undefined' - | 'NaN' | 'Infinity' | id + | 'true' | 'false' | 'null' | 'undefined' | 'NaN' + | infinity | id +infinity ::= '-'? 'Infinity' ``` -A `-` is **not an operator**. It is part of the token that follows it, and -only where that token is a number, a bigint, or `Infinity`. `-NaN`, -`-undefined`, `-true` and a bare `-` are rejected. +A `-` is **not an operator**: it belongs to the token that follows it, and the +grammar says so rather than leaving it to prose. Three productions carry the +optional sign — `number`, `bigint` and `infinity` — and nothing else does, so +`-NaN`, `-undefined`, `-true` and a bare `-` have no rule that accepts them. #### Strings @@ -164,9 +178,8 @@ document ::= const* export const ::= 'const' id '=' value ';' export ::= 'export' 'default' value ';' -value ::= 'null' | 'true' | 'false' | 'undefined' - | 'NaN' | 'Infinity' | '-Infinity' - | number | bigint | string +value ::= 'null' | 'true' | 'false' | 'undefined' | 'NaN' + | infinity | number | bigint | string | array | object | id array ::= '[' (value (',' value)*)? ']' @@ -422,6 +435,13 @@ or compare documents. **Every JSON value is a DataJS value. No JSON document is a DataJS document** — a DataJS document is a JavaScript module, which a JSON document is not. +Two different mechanisms exclude them, and an implementer checking documents +should know which applies. Object-shaped JSON such as `{"a":1}` is **not valid +JavaScript** at the top level of a module at all: the `{` opens a block. Scalar +and array JSON — `42`, `"txt"`, `[1,2]` — *is* valid JavaScript, and fails the +later test instead: it declares no `export default`, so it is a module that +exports nothing. + The conversion is textual: ```text From 1af1ddd5e2926040936975914ffbac2afd948200 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 03:02:55 +0000 Subject: [PATCH 355/370] spec/datajs: primitive sharing converts to JSON; zero bigint is 0n Two review findings. The reverse JSON conversion refused any document where "no value is shared", which forbids const x=1;export default [x,x];. Primitives have no reference identity, so [1,1] denotes exactly that value - the same reasoning normalized form already uses when it declines to hoist them. The condition now names objects and arrays. Normalized form said bigints are their digits plus n without settling zero, and the grammar accepts -0n as an input spelling of 0n, so two normalizers could emit different bytes for one value. Zero is 0n. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/datajs/README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/spec/datajs/README.md b/spec/datajs/README.md index 513e7d9a7..434ad40ab 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -412,7 +412,9 @@ positive exponent carries `+`; there is no uppercase `E` spelling, and no "shortest form" tie left to break. **Bigints** are their full decimal digits followed by `n`, never exponent -notation — which would read back as a number. +notation — which would read back as a number. A negative bigint carries `-`; +zero is `0n` and never `-0n`, which the grammar accepts as an input spelling +of the same value and normalized form must therefore not emit. **Strings** are spelled by ECMAScript's `QuoteJSONString`, the algorithm `JSON.stringify` uses for a string: the escapes `\"` `\\` `\b` `\t` `\n` `\f` @@ -454,8 +456,13 @@ no `__proto__` key, plain concatenation is exactly a valid DataJS document. The reverse direction is partial. A DataJS document converts to JSON only when it uses no leaf JSON lacks (`undefined`, `NaN`, the infinities, bigint) and no -value is shared — JSON cannot express the sharing, and emitting the value twice -denotes a different graph. +**object or array** is reachable more than once — JSON cannot express that +sharing, and writing the node twice denotes a different graph. + +A shared *primitive* is not an obstacle. `const x=1;export default [x,x];` +converts to `[1,1]`: primitives have no reference identity, so the two +occurrences were never distinguishable from two copies, exactly as +[normalized form](#normalized-form) says when it declines to hoist them. ## Relationship to FunctionalScript and JavaScript From d14ae13eb55f2529ec8f0971040a97aea87830b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 03:10:00 +0000 Subject: [PATCH 356/370] spec/datajs: JSON conversion judges the exported graph only Review finding, and the direct sibling of the previous commit: scoping the sharing condition to reachable objects left the leaf condition unscoped, so const dead=undefined;export default 1; was rejected even though the document denotes just 1. Both halves now speak about values reachable from export default, which is what the document means; an unused const contributes nothing to it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/datajs/README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/spec/datajs/README.md b/spec/datajs/README.md index 434ad40ab..b745fe3a0 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -454,10 +454,15 @@ with one exception: a bare `"__proto__"` key must be rewritten to `["__proto__"]`, since DataJS rejects the string spelling. For JSON containing no `__proto__` key, plain concatenation is exactly a valid DataJS document. -The reverse direction is partial. A DataJS document converts to JSON only when -it uses no leaf JSON lacks (`undefined`, `NaN`, the infinities, bigint) and no -**object or array** is reachable more than once — JSON cannot express that -sharing, and writing the node twice denotes a different graph. +The reverse direction is partial, and both of its conditions are about the +graph the document *denotes* — the values reachable from `export default`, +since an unused `const` contributes nothing to it. A document converts to JSON +when no reachable value is a leaf JSON lacks (`undefined`, `NaN`, the +infinities, bigint), and no reachable **object or array** is reachable more +than once — JSON cannot express that sharing, and writing the node twice +denotes a different graph. `const dead=undefined;export default 1;` therefore +converts to `1`: the unreachable `undefined` is not part of what the document +means. A shared *primitive* is not an obstacle. `const x=1;export default [x,x];` converts to `[1,1]`: primitives have no reference identity, so the two From e20770c7cfdb9414e4d7a2f80e52012b6c20c157 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 03:18:15 +0000 Subject: [PATCH 357/370] spec/datajs: conformance by role; fix the duplicated summary Two review findings. The pointer this PR added to spec/README.md still said "a DAG and nothing else" - the same overstatement already corrected in the spec's own summary, left behind in a second location. A reader entering through the language spec would have missed the extra leaves entirely. Conformance was one global condition about accepting and rejecting documents, which a serializer-only library cannot satisfy and which said nothing about what a reader-only library owes. It is now stated per role - reader, serializer, normalized serializer - with implementations declaring which they provide and judged on those. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/README.md | 8 +++++--- spec/datajs/README.md | 19 +++++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/spec/README.md b/spec/README.md index 3079efc4e..3667b4ef1 100644 --- a/spec/README.md +++ b/spec/README.md @@ -23,9 +23,11 @@ feature belongs to is a statement about where it lands, not about what is implemented now. **DJS here is not DataJS.** [`spec/datajs/`](./datajs/README.md) specifies -**DataJS**, a much narrower interchange format: JSON extended from a tree to a -DAG and nothing else, with `;`-terminated statements, no `import`, no comments, -no identifier keys and no trailing commas. The data subset described in *this* +**DataJS**, a much narrower interchange format: JSON with two extensions — +values may be shared, so a document denotes a DAG rather than a tree, and the +leaf set gains `undefined`, `bigint`, `NaN` and the infinities — and with +`;`-terminated statements, no `import`, no comments, no identifier keys and no +trailing commas. The data subset described in *this* document is wider and is what the compiler accepts today. The two converge as [`todo/parser-serializer-restructure.md`](../todo/parser-serializer-restructure.md) proceeds. diff --git a/spec/datajs/README.md b/spec/datajs/README.md index b745fe3a0..f08ef5802 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -499,10 +499,21 @@ served for both needs a deliberate choice. ## Conformance -An implementation conforms if it accepts every document this specification -accepts, rejects every document it rejects, and denotes the graph described -here. The machine-readable accept / reject / round-trip corpus that decides -this is +Conformance is per role, because an implementation may provide only one of +them — a library that just writes DataJS accepts no documents at all, and one +that just reads it emits none. + +- A conforming **reader** accepts every document this specification accepts, + rejects every document it rejects, and yields the graph the document + denotes, sharing included. +- A conforming **serializer** rejects every input outside + [the data model](#what-may-be-serialized) and otherwise emits a valid + document denoting the input graph. +- A conforming **normalized serializer** is a conforming serializer whose + output is the byte sequence [normalized form](#normalized-form) defines. + +An implementation states which roles it provides, and is judged only on those. +The machine-readable corpus that decides each is [`spec/datajs/todo/conformance-vectors.md`](./todo/conformance-vectors.md); until it lands, this prose is the only statement of conformance. From 1786439f6eb1a780fbb5a28ffe2fbb3f1cdc819d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 03:24:14 +0000 Subject: [PATCH 358/370] spec/datajs: property attributes are outside the data model Review finding identified a real gap - the spec never said whether writable/configurable matter to a serializer - but proposed rejecting non-writable, non-configurable data properties, which cannot be right: Object.freeze produces exactly that descriptor, so the rule would make every frozen value unserializable, including the output of a reader that freezes what it returns, which this specification explicitly permits. The gap is closed the other way. Attributes describe the slot, not the value; DataJS has no syntax for them; a serializer neither inspects nor preserves them. Enumerability and accessors stay rejected because they change which values appear at all. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/datajs/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/datajs/README.md b/spec/datajs/README.md index f08ef5802..f03724c5e 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -342,6 +342,18 @@ approximated: syntax holds only elements, so `meta` has nowhere to go; - a cycle. +**Property attributes are not part of the data model.** `writable`, +`configurable` and the object's extensibility describe the *slot*, not the +value in it, and DataJS has no syntax for them. A serializer neither inspects +nor preserves them: a frozen `{x:1}` and an ordinary one serialize alike, and +what reads back is an ordinary object. This is not an oversight to fix by +rejecting unusual descriptors — `Object.freeze` makes every property +non-writable and non-configurable, so that rule would make frozen values +unserializable, including the output of a reader that freezes what it returns, +which this specification explicitly permits. Enumerability and accessors are +different, and rejected above, because they change *which values appear at +all*. + Every one of these is a case where the obvious implementation quietly produces a document denoting something else. `JSON.stringify` substitutes `null` for a function, expands a hole to `null`, drops a symbol-keyed member, and drops that From b21ceffde10220fa734715d9fa8d5990610d779e Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 20:25:53 -0700 Subject: [PATCH 359/370] ci todo: clarify TypeScript tool ownership --- fjs/ci/todo/typescript-ci-tool.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 751b79e97..8381a59b7 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -5,15 +5,17 @@ ### Problem -TypeScript is currently installed through the root `package.json` `devDependencies`, so every `npm ci` installs it even in CI jobs that do not run `tsc`. +TypeScript is currently owned by the root `package.json` `devDependencies`. That makes the compiler an implicit dependency of every npm install, including runtime-compatibility jobs that do not type-check, and makes npm package metadata the source of the repository's compiler version. -TypeScript is a development/CI tool rather than a runtime package dependency. CI already has infrastructure for pinning and provisioning tool versions, so TypeScript should be owned there instead of by npm package metadata. +TypeScript is a development/CI tool rather than a runtime package dependency. CI already owns versions of development tools and should own the TypeScript pin as well. This also decouples the TypeScript version from the Node/Deno/Bun runtime matrix so CI can change or add compiler-version checks independently of npm dependencies. + +This is an ownership/decoupling change, not a CI-performance optimization justified by a timing benchmark. Avoiding TypeScript installation in jobs that do not use it is a direct consequence, not the acceptance criterion. The migration must not replace the npm pin with multiple independent compiler pins: every CI/development environment that needs TypeScript should derive the same pinned version from the CI tool configuration. This task is intentionally limited to TypeScript. Keep `@types/node` in `devDependencies`. ### Goal -Provision a pinned TypeScript version through the CI tool environment and remove `typescript` from the root `package.json` `devDependencies`. +Make the CI tool configuration the single repository-owned TypeScript version pin, provision that compiler only in environments that need it, and remove `typescript` from the root `package.json` `devDependencies`. Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job, its generated Node 26 Nix environment, packed-package check, and package publishing path do need the pinned compiler because they invoke `tsc` directly, provide the canonical development toolchain, install it for declaration validation, or invoke it through npm lifecycle scripts such as `prepack`. @@ -23,7 +25,7 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou ### Tasks -- [ ] Add a pinned TypeScript version to the CI tool configuration. +- [ ] Add the single pinned TypeScript version to the CI tool configuration; do not introduce another repository-owned TypeScript version pin elsewhere. - [ ] Make the packed-package check read its compiler pin from that CI configuration instead of `package.json` so removing `devDependencies.typescript` does not remove `package-check`; update the related proofs for the new pin source. - [ ] Preserve `package-check` isolation. Prefer Nix only if the pinned compiler can be supplied without checking out the repository or exposing repository `tsconfig.json`, sources, or `node_modules` to the packed-package consumer (for example, through an isolated Nix environment available independently of the checkout). Otherwise provision the pinned TypeScript explicitly on `PATH` and change the check from `npx tsc` to `tsc`; verify it cannot fall back to an unrelated ambient compiler. - [ ] Provision that TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). @@ -32,11 +34,12 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Run `tsc` from `PATH` instead of relying on `npx tsc` / `node_modules/.bin/tsc`. - [ ] Remove `typescript` from the root `package.json` `devDependencies`, then run `npm run update` so `package-lock.json`, `deno.lock`, `bun.lock`, and generated CI files are all regenerated consistently. - [ ] Keep `@types/node` as a devDependency. -- [ ] Update repository-owned developer/check documentation, including `CONTRIBUTING.md`, `AGENTS.md`, `fjs/AGENTS.md`, and `fjs/ci/README.md`: list TypeScript as a developer tool where appropriate, document installing the pinned version globally for local development, and replace required `npx tsc` instructions with `tsc`. -- [ ] Update the Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks have the pinned TypeScript on `PATH` without relying on the root devDependency. +- [ ] Update repository-owned developer/check documentation, including `CONTRIBUTING.md`, `AGENTS.md`, `fjs/AGENTS.md`, and `fjs/ci/README.md`: list TypeScript as a developer tool where appropriate, document installing the CI-pinned version globally for local development, and replace required `npx tsc` instructions with `tsc`. +- [ ] Update the Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks have the CI-pinned TypeScript on `PATH` without relying on the root devDependency. - [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily and their frozen-lock installs still succeed. - [ ] Verify the generated Node 26 Nix shell provides the pinned `tsc` and can run the canonical type-check/package commands without a local TypeScript devDependency. - [ ] Verify `package-check` remains checkout-free and validates only the packed declarations with the CI-configured compiler pin and intended `tsc` on `PATH`. +- [ ] Verify every environment uses the CI-configured TypeScript version rather than maintaining an independent pin. - [ ] Verify `tsc`, `npm test`, `npm pack`, and the npm publish path work in every environment that is documented or responsible for those checks. ### Related From f905f69fd51008adcb8e5c8dec8ce7863b384172 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 03:30:23 +0000 Subject: [PATCH 360/370] spec/datajs: state the value-versus-object line once, not per case Third finding in this class - after attributes came prototypes: a null-prototype array or an Array subclass passes every rejection rule and reads back ordinary. Patching prototypes specifically would invite the next member of the class (extensibility, exotic host state), so the rule is now general. A serializer reads an object's own enumerable string-keyed data properties and an array's elements, and nothing else: attributes, extensibility, prototype and any other host attachment are outside the data model, because DataJS has no syntax that could carry them. A round trip preserves the value, not the object. Enumerability and accessors stay rejected rather than ignored, and the text now draws the line explicitly: they change which values appear at all, which is a question about the data, while everything newly listed is a question about the object holding it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/datajs/README.md | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/spec/datajs/README.md b/spec/datajs/README.md index f03724c5e..91f738efe 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -342,17 +342,30 @@ approximated: syntax holds only elements, so `meta` has nowhere to go; - a cycle. -**Property attributes are not part of the data model.** `writable`, -`configurable` and the object's extensibility describe the *slot*, not the -value in it, and DataJS has no syntax for them. A serializer neither inspects -nor preserves them: a frozen `{x:1}` and an ordinary one serialize alike, and -what reads back is an ordinary object. This is not an oversight to fix by -rejecting unusual descriptors — `Object.freeze` makes every property -non-writable and non-configurable, so that rule would make frozen values -unserializable, including the output of a reader that freezes what it returns, -which this specification explicitly permits. Enumerability and accessors are -different, and rejected above, because they change *which values appear at -all*. +**Only the data is in the model.** What a serializer reads from an object is +its own enumerable string-keyed data properties, and from an array its +elements; what it reads from those is their values. Everything else about the +host object is outside the data model, because DataJS has no syntax for any of +it and therefore cannot carry it: + +- property attributes — `writable`, `configurable`, and whether the object is + extensible, sealed or frozen; +- the prototype — a `null`-prototype object, a `null`-prototype array, or an + `Array` subclass all serialize as their data, and read back ordinary; +- anything else the host attaches that is not an own enumerable string-keyed + data property. + +A round trip therefore preserves the **value**, not the object. This is +deliberate and not a gap to close by rejecting the unusual cases: `Object.freeze` +makes every property non-writable and non-configurable, so rejecting those +descriptors would make frozen values unserializable — including the output of a +reader that freezes what it returns, which this specification explicitly +permits. + +Enumerability and accessors are the exception, rejected above rather than +ignored here, and the line is worth stating: they change *which values appear +at all*, which is a question about the data. Everything in this section's list +is a question about the object holding it. Every one of these is a case where the obvious implementation quietly produces a document denoting something else. `JSON.stringify` substitutes `null` for a From 9bf6576dc6f78ac02737a4509e142cea975e9768 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 20:34:01 -0700 Subject: [PATCH 361/370] todo: define pin-derived TypeScript Nix tool --- fjs/ci/todo/typescript-ci-tool.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 8381a59b7..177913709 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -9,27 +9,31 @@ TypeScript is currently owned by the root `package.json` `devDependencies`. That TypeScript is a development/CI tool rather than a runtime package dependency. CI already owns versions of development tools and should own the TypeScript pin as well. This also decouples the TypeScript version from the Node/Deno/Bun runtime matrix so CI can change or add compiler-version checks independently of npm dependencies. -This is an ownership/decoupling change, not a CI-performance optimization justified by a timing benchmark. Avoiding TypeScript installation in jobs that do not use it is a direct consequence, not the acceptance criterion. The migration must not replace the npm pin with multiple independent compiler pins: every CI/development environment that needs TypeScript should derive the same pinned version from the CI tool configuration. +This is an ownership/decoupling change, not a CI-performance optimization justified by a timing benchmark. Avoiding TypeScript installation in jobs that do not use it is a direct consequence, not the acceptance criterion. The migration must not replace the npm pin with multiple independent compiler pins: every CI/development environment that needs TypeScript should derive the same pinned tool definition from the CI configuration. This task is intentionally limited to TypeScript. Keep `@types/node` in `devDependencies`. ### Goal -Make the CI tool configuration the single repository-owned TypeScript version pin, provision that compiler only in environments that need it, and remove `typescript` from the root `package.json` `devDependencies`. +Make the CI tool configuration the single repository-owned TypeScript tool definition, including its exact version and source integrity, provision that compiler only in environments that need it, and remove `typescript` from the root `package.json` `devDependencies`. Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job, its generated Node 26 Nix environment, packed-package check, and package publishing path do need the pinned compiler because they invoke `tsc` directly, provide the canonical development toolchain, install it for declaration validation, or invoke it through npm lifecycle scripts such as `prepack`. +The Nix package must be derived from the CI TypeScript definition, not selected independently by the pinned Nixpkgs snapshot. Extend the Nix tool model beyond bare `pkgs.` packages for this case: fetch the official `typescript-.tgz` npm artifact using the CI-configured version and SRI hash, build/install it as a Nix package that exposes `tsc` on `PATH`, and use that derivation in the generated Node 26 environment. Do not use `pkgs.typescript`/`pkgs.nodePackages.typescript` as the source of the compiler version. This keeps TypeScript version changes independent of Node and Nixpkgs updates. + Prefer migrating an environment to Nix before adding an npm/global-install or manual `PATH` workaround. The direct-Nix migration is tracked by [65Z-ci-nix](65z-ci-nix.md) and its concrete Node-job implementation [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). A Nix migration must preserve the environment's existing isolation guarantees; in particular, `package-check` must remain a checkout-free packed-package consumer rather than gaining access to repository sources or `tsconfig.json` merely so it can reach a relative flake. If Nix cannot provide the compiler while preserving that isolation, keep explicit pinned `PATH` provisioning for that job instead. Local development must continue to support `tsc`, `npm test`, and `npm pack`: outside an environment that provides the compiler, developers install the pinned TypeScript globally so `tsc` is available on `PATH`. ### Tasks -- [ ] Add the single pinned TypeScript version to the CI tool configuration; do not introduce another repository-owned TypeScript version pin elsewhere. +- [ ] Add the single pinned TypeScript tool definition to the CI configuration: exact version plus the SRI hash of the official npm package artifact. Do not introduce another repository-owned TypeScript version pin elsewhere. +- [ ] Extend the Nix package model/generator to support the pin-derived TypeScript tool: fetch `typescript-.tgz` from npm using the configured SRI hash and produce a Nix package exposing `tsc` on `PATH`. The generated compiler must not take its version from Nixpkgs. +- [ ] Add proofs for the pin-derived Nix tool generation, including version/hash propagation and the generated Node 26 package set. - [ ] Make the packed-package check read its compiler pin from that CI configuration instead of `package.json` so removing `devDependencies.typescript` does not remove `package-check`; update the related proofs for the new pin source. - [ ] Preserve `package-check` isolation. Prefer Nix only if the pinned compiler can be supplied without checking out the repository or exposing repository `tsconfig.json`, sources, or `node_modules` to the packed-package consumer (for example, through an isolated Nix environment available independently of the checkout). Otherwise provision the pinned TypeScript explicitly on `PATH` and change the check from `npx tsc` to `tsc`; verify it cannot fall back to an unrelated ambient compiler. - [ ] Provision that TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). -- [ ] Add the pinned TypeScript package to the generated Node 26 Nix environment (`nodeNixJobs`) so the canonical development shell provides `tsc`; update its proofs/generated-flake expectations. This extends the Node 26 migration tracked by [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). +- [ ] Add the pin-derived TypeScript Nix package to the generated Node 26 environment (`nodeNixJobs`) so the canonical development shell provides `tsc`; update its proofs/generated-flake expectations. This extends the Node 26 migration tracked by [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). - [ ] Prefer moving npm publishing to Nix first and provide the pinned TypeScript there so `prepack` uses the intended compiler during `npm publish`. Coordinate this with [668-ci-npm-publish-workflow](668-ci-npm-publish-workflow.md), which owns generation of the publish workflow. Only add a separate compiler-install step if publishing remains outside Nix. - [ ] Run `tsc` from `PATH` instead of relying on `npx tsc` / `node_modules/.bin/tsc`. - [ ] Remove `typescript` from the root `package.json` `devDependencies`, then run `npm run update` so `package-lock.json`, `deno.lock`, `bun.lock`, and generated CI files are all regenerated consistently. @@ -37,7 +41,8 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Update repository-owned developer/check documentation, including `CONTRIBUTING.md`, `AGENTS.md`, `fjs/AGENTS.md`, and `fjs/ci/README.md`: list TypeScript as a developer tool where appropriate, document installing the CI-pinned version globally for local development, and replace required `npx tsc` instructions with `tsc`. - [ ] Update the Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks have the CI-pinned TypeScript on `PATH` without relying on the root devDependency. - [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily and their frozen-lock installs still succeed. -- [ ] Verify the generated Node 26 Nix shell provides the pinned `tsc` and can run the canonical type-check/package commands without a local TypeScript devDependency. +- [ ] Verify the generated Node 26 Nix shell provides exactly the CI-configured TypeScript version and can run the canonical type-check/package commands without a local TypeScript devDependency. +- [ ] Verify changing the CI TypeScript version/hash does not require changing the Node version or Nixpkgs snapshot. - [ ] Verify `package-check` remains checkout-free and validates only the packed declarations with the CI-configured compiler pin and intended `tsc` on `PATH`. - [ ] Verify every environment uses the CI-configured TypeScript version rather than maintaining an independent pin. - [ ] Verify `tsc`, `npm test`, `npm pack`, and the npm publish path work in every environment that is documented or responsible for those checks. From a07b33555cf6cd3770e0b1a4007a6e9376165747 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 03:35:45 +0000 Subject: [PATCH 362/370] spec/datajs: the __proto__ rule is on the decoded value Review finding: key ::= string admits every JSON spelling, so a rule written against the source text "__proto__" left {"__proto__":1} accepted. Confirmed the two spellings are identical to JavaScript - both assign the prototype and create no own property - so an implementation matching text would accept a document and read back an own property the engine never created, which is exactly the accept-and-mean-differently failure the subset law forbids. The rejection is now on the decoded value, in the key rule and in the JSON conversion, and the escaped spelling is pinned as a reject vector. The computed form keeps its single spelling ["__proto__"], since one spelling is the point of the rule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/datajs/README.md | 16 +++++++++++++--- spec/datajs/todo/conformance-vectors.md | 3 ++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/spec/datajs/README.md b/spec/datajs/README.md index 91f738efe..314d6988f 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -243,6 +243,14 @@ computed form: export default {["__proto__"]:1}; ``` +The rule is on the key's **decoded value**, not its spelling: a plain string +key is rejected whenever it decodes to `__proto__`, so `{"\u005f_proto__":1}` +is rejected exactly as `{"__proto__":1}` is. JavaScript decides the same way — +the escaped form is a prototype assignment too, and an implementation matching +source text instead would accept it and read back an own property JavaScript +never created. The computed form is spelled `["__proto__"]` and only that, +since one spelling is the point of the rule. + A bare `__proto__` key and the string form `{"__proto__":1}` are **rejected**, because JavaScript reads them as an instruction to replace the object's prototype rather than as data. The computed form is an ordinary own property @@ -475,9 +483,11 @@ The conversion is textual: "export default " + json + ";" ``` -with one exception: a bare `"__proto__"` key must be rewritten to -`["__proto__"]`, since DataJS rejects the string spelling. For JSON containing -no `__proto__` key, plain concatenation is exactly a valid DataJS document. +with one exception: a key **decoding to** `__proto__` must be rewritten to +`["__proto__"]`, since DataJS rejects every plain-string spelling of it. That +covers escaped spellings such as `"\u005f_proto__"`, which JSON and DataJS +both read as the same key. For JSON containing no such key, plain +concatenation is exactly a valid DataJS document. The reverse direction is partial, and both of its conditions are about the graph the document *denotes* — the values reachable from `export default`, diff --git a/spec/datajs/todo/conformance-vectors.md b/spec/datajs/todo/conformance-vectors.md index 76e7d7653..4b32fa08e 100644 --- a/spec/datajs/todo/conformance-vectors.md +++ b/spec/datajs/todo/conformance-vectors.md @@ -28,7 +28,8 @@ A machine-readable corpus with three parts: containers, deep nesting, and shared nodes reached by several paths. - **reject** — document text plus what is wrong with it. Cases: a missing or non-final `export default`, a missing `;`, `;;`, a trailing comma, a comment, - an `import`, an identifier key, a bare or string `"__proto__"` key, `1.5n`, + an `import`, an identifier key, a bare or string `"__proto__"` key and its escaped spelling + `"\u005f_proto__"` (the rule is on the decoded value), `1.5n`, `1e2n`, `01n`, `-NaN`, `-undefined`, a bare `-`, a forward or unbound reference, a rebound name, each excluded const name, single quotes, `\x` and `\u{…}` escapes, U+2028/U+2029/NBSP/FF/BOM outside a string. From 06e48d3d5ecb9013ac9a25a9ee0926269176e50f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 03:35:00 +0000 Subject: [PATCH 363/370] changelog: give the entry its topic prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `changelog/README.md` asks for the `Topic: short description` form, and every other entry in `unreleased/` carries one — `rtti`, `effects`, `web`, `emergent_testing`, `fjs/fsc`. This one started with the change itself, so a reader scanning a release's notes had nothing to sort it by. `package`, matching what the change is about: what the published npm package contains. 214 characters over three lines, still inside the documented limit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- changelog/unreleased/1771.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog/unreleased/1771.md b/changelog/unreleased/1771.md index d848b2091..0b22d9346 100644 --- a/changelog/unreleased/1771.md +++ b/changelog/unreleased/1771.md @@ -1,3 +1,3 @@ -- Generated `private.d.ts` files are no longer published: `package.json`'s - `files` excludes them, dropping 16 files from the package. They were +- `package`: generated `private.d.ts` files are no longer published. + `package.json`'s `files` excludes them, dropping 16 files. They were implementation-private by contract, so nothing public depended on them. From a5ff63d065d16e449d911d28f05bb222a95667de Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 20:40:25 -0700 Subject: [PATCH 364/370] ci todo: delegate TypeScript dependencies to installers --- fjs/ci/todo/typescript-ci-tool.md | 40 ++++++++++++++++--------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 177913709..6af65ba6d 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -9,41 +9,43 @@ TypeScript is currently owned by the root `package.json` `devDependencies`. That TypeScript is a development/CI tool rather than a runtime package dependency. CI already owns versions of development tools and should own the TypeScript pin as well. This also decouples the TypeScript version from the Node/Deno/Bun runtime matrix so CI can change or add compiler-version checks independently of npm dependencies. -This is an ownership/decoupling change, not a CI-performance optimization justified by a timing benchmark. Avoiding TypeScript installation in jobs that do not use it is a direct consequence, not the acceptance criterion. The migration must not replace the npm pin with multiple independent compiler pins: every CI/development environment that needs TypeScript should derive the same pinned tool definition from the CI configuration. +This is an ownership/decoupling change, not a CI-performance optimization justified by a timing benchmark. Avoiding TypeScript installation in jobs that do not use it is a direct consequence, not the acceptance criterion. The migration must not replace the npm pin with multiple independent compiler pins: every CI/development environment that needs TypeScript should derive the same version from the CI configuration. + +Provision tools through installation/package-management tools. npm environments should install the configured TypeScript package with npm; Nix environments should package/install it through Nix's standard npm/package mechanisms. FunctionalScript CI must not reproduce TypeScript's dependency resolver by enumerating, fetching, or wiring platform-specific optional packages such as `@typescript/typescript-*` itself. Platform and transitive dependencies belong to npm/Nix. This task is intentionally limited to TypeScript. Keep `@types/node` in `devDependencies`. ### Goal -Make the CI tool configuration the single repository-owned TypeScript tool definition, including its exact version and source integrity, provision that compiler only in environments that need it, and remove `typescript` from the root `package.json` `devDependencies`. +Make the CI tool configuration the single repository-owned TypeScript version pin, provision that compiler only in environments that need it through their normal installation tooling, and remove `typescript` from the root `package.json` `devDependencies`. -Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job, its generated Node 26 Nix environment, packed-package check, and package publishing path do need the pinned compiler because they invoke `tsc` directly, provide the canonical development toolchain, install it for declaration validation, or invoke it through npm lifecycle scripts such as `prepack`. +Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job, its generated Node 26 Nix environment, packed-package check, and package publishing path do need the pinned compiler because they invoke `tsc` directly, provide the canonical development toolchain, validate declarations, or invoke it through npm lifecycle scripts such as `prepack`. -The Nix package must be derived from the CI TypeScript definition, not selected independently by the pinned Nixpkgs snapshot. Extend the Nix tool model beyond bare `pkgs.` packages for this case: fetch the official `typescript-.tgz` npm artifact using the CI-configured version and SRI hash, build/install it as a Nix package that exposes `tsc` on `PATH`, and use that derivation in the generated Node 26 environment. Do not use `pkgs.typescript`/`pkgs.nodePackages.typescript` as the source of the compiler version. This keeps TypeScript version changes independent of Node and Nixpkgs updates. +The Nix package must derive from the CI TypeScript version rather than silently taking whatever TypeScript version the pinned Nixpkgs snapshot happens to expose. Use a standard Nix mechanism for packaging an npm package (for example, the appropriate Nix npm-package builder) so Nix/npm resolves and verifies the complete dependency closure, including platform-specific optional dependencies. Any Nix dependency hash or generated package metadata should be produced as part of that standard packaging flow; do not maintain a FunctionalScript-specific list of TypeScript tarballs or platform packages. -Prefer migrating an environment to Nix before adding an npm/global-install or manual `PATH` workaround. The direct-Nix migration is tracked by [65Z-ci-nix](65z-ci-nix.md) and its concrete Node-job implementation [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). A Nix migration must preserve the environment's existing isolation guarantees; in particular, `package-check` must remain a checkout-free packed-package consumer rather than gaining access to repository sources or `tsconfig.json` merely so it can reach a relative flake. If Nix cannot provide the compiler while preserving that isolation, keep explicit pinned `PATH` provisioning for that job instead. +Prefer migrating an environment to Nix before adding a separate npm-global install. The direct-Nix migration is tracked by [65Z-ci-nix](65z-ci-nix.md) and its concrete Node-job implementation [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). A Nix migration must preserve the environment's existing isolation guarantees; in particular, `package-check` must remain a checkout-free packed-package consumer rather than gaining access to repository sources or `tsconfig.json` merely so it can reach a relative flake. If Nix cannot provide the compiler while preserving that isolation, install the CI-pinned TypeScript with npm inside the isolated consumer environment instead. -Local development must continue to support `tsc`, `npm test`, and `npm pack`: outside an environment that provides the compiler, developers install the pinned TypeScript globally so `tsc` is available on `PATH`. +Local development must continue to support `tsc`, `npm test`, and `npm pack`: outside an environment that provides the compiler, developers install the CI-pinned TypeScript globally with npm so `tsc` is available on `PATH`. ### Tasks -- [ ] Add the single pinned TypeScript tool definition to the CI configuration: exact version plus the SRI hash of the official npm package artifact. Do not introduce another repository-owned TypeScript version pin elsewhere. -- [ ] Extend the Nix package model/generator to support the pin-derived TypeScript tool: fetch `typescript-.tgz` from npm using the configured SRI hash and produce a Nix package exposing `tsc` on `PATH`. The generated compiler must not take its version from Nixpkgs. -- [ ] Add proofs for the pin-derived Nix tool generation, including version/hash propagation and the generated Node 26 package set. -- [ ] Make the packed-package check read its compiler pin from that CI configuration instead of `package.json` so removing `devDependencies.typescript` does not remove `package-check`; update the related proofs for the new pin source. -- [ ] Preserve `package-check` isolation. Prefer Nix only if the pinned compiler can be supplied without checking out the repository or exposing repository `tsconfig.json`, sources, or `node_modules` to the packed-package consumer (for example, through an isolated Nix environment available independently of the checkout). Otherwise provision the pinned TypeScript explicitly on `PATH` and change the check from `npx tsc` to `tsc`; verify it cannot fall back to an unrelated ambient compiler. -- [ ] Provision that TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). -- [ ] Add the pin-derived TypeScript Nix package to the generated Node 26 environment (`nodeNixJobs`) so the canonical development shell provides `tsc`; update its proofs/generated-flake expectations. This extends the Node 26 migration tracked by [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). -- [ ] Prefer moving npm publishing to Nix first and provide the pinned TypeScript there so `prepack` uses the intended compiler during `npm publish`. Coordinate this with [668-ci-npm-publish-workflow](668-ci-npm-publish-workflow.md), which owns generation of the publish workflow. Only add a separate compiler-install step if publishing remains outside Nix. -- [ ] Run `tsc` from `PATH` instead of relying on `npx tsc` / `node_modules/.bin/tsc`. +- [ ] Add the single exact TypeScript version pin to the CI configuration. Do not introduce another repository-owned TypeScript version pin elsewhere. +- [ ] Extend the Nix tool model/generator to install that TypeScript version through a standard Nix npm-package mechanism. Let Nix/npm resolve and verify TypeScript's complete transitive/platform dependency closure; do not fetch or wire TypeScript platform packages manually. +- [ ] Add proofs for the Nix TypeScript tool generation, including version propagation and the generated Node 26 package set. +- [ ] Make the packed-package check read its compiler version from the CI configuration instead of `package.json` so removing `devDependencies.typescript` does not remove `package-check`; update the related proofs for the new pin source. +- [ ] Preserve `package-check` isolation. Prefer Nix only if the compiler can be supplied without checking out the repository or exposing repository `tsconfig.json`, sources, or `node_modules` to the packed-package consumer. Otherwise install `typescript@` with npm inside the isolated consumer environment and expose that installation's `tsc`; npm, not CI code, resolves platform dependencies. +- [ ] Provision the configured TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). +- [ ] Add the Nix-packaged TypeScript tool to the generated Node 26 environment (`nodeNixJobs`) so the canonical development shell provides `tsc`; update its proofs/generated-flake expectations. This extends the Node 26 migration tracked by [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). +- [ ] Prefer moving npm publishing to Nix first and provide TypeScript there so `prepack` uses the intended compiler during `npm publish`. Coordinate this with [668-ci-npm-publish-workflow](668-ci-npm-publish-workflow.md), which owns generation of the publish workflow. If publishing remains outside Nix, install the CI-pinned compiler with npm rather than manually assembling its files or dependencies. +- [ ] Run `tsc` from the environment-provided installation instead of relying on an implicit root `node_modules/.bin/tsc`. - [ ] Remove `typescript` from the root `package.json` `devDependencies`, then run `npm run update` so `package-lock.json`, `deno.lock`, `bun.lock`, and generated CI files are all regenerated consistently. - [ ] Keep `@types/node` as a devDependency. -- [ ] Update repository-owned developer/check documentation, including `CONTRIBUTING.md`, `AGENTS.md`, `fjs/AGENTS.md`, and `fjs/ci/README.md`: list TypeScript as a developer tool where appropriate, document installing the CI-pinned version globally for local development, and replace required `npx tsc` instructions with `tsc`. -- [ ] Update the Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks have the CI-pinned TypeScript on `PATH` without relying on the root devDependency. +- [ ] Update repository-owned developer/check documentation, including `CONTRIBUTING.md`, `AGENTS.md`, `fjs/AGENTS.md`, and `fjs/ci/README.md`: list TypeScript as a developer tool where appropriate, document `npm install -g typescript@` for non-Nix local development, and replace required `npx tsc` instructions with `tsc` where the environment provides it. +- [ ] Update Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks install the CI-pinned TypeScript through Nix or npm, without relying on the root devDependency or manually installing platform dependencies. - [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily and their frozen-lock installs still succeed. - [ ] Verify the generated Node 26 Nix shell provides exactly the CI-configured TypeScript version and can run the canonical type-check/package commands without a local TypeScript devDependency. -- [ ] Verify changing the CI TypeScript version/hash does not require changing the Node version or Nixpkgs snapshot. -- [ ] Verify `package-check` remains checkout-free and validates only the packed declarations with the CI-configured compiler pin and intended `tsc` on `PATH`. +- [ ] Verify changing the CI TypeScript version does not require changing the Node version or manually updating a platform-package list. +- [ ] Verify `package-check` remains checkout-free and uses the CI-configured TypeScript installation without falling back to an unrelated ambient compiler. - [ ] Verify every environment uses the CI-configured TypeScript version rather than maintaining an independent pin. - [ ] Verify `tsc`, `npm test`, `npm pack`, and the npm publish path work in every environment that is documented or responsible for those checks. From 3b97b91672ddf3c65ed9cc8e073339e5b64f4e24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 03:41:48 +0000 Subject: [PATCH 365/370] spec/datajs: no media type of its own, per the dialect design Review finding, and the reviewer is right that this contradicted an existing concrete design. fjs/todo/group-fs-subdirectories-by-concern.md already settled media types for FunctionalScript's formats: JavaScript subsets get none, because RFC 9239 closes the JavaScript MIME list and there is no +javascript suffix, so a vendor type is opaque to every consumer. They are served as text/javascript with the dialect out of band. I had proposed application/datajs and treated the browser-import problem as a caveat; that document treats it as decisive, and it is - a type of our own would break the one thing a DataJS document is guaranteed to be, a module a browser can import. The spec now follows it: text/javascript with dialect vnd.fjs.datajs+vnd.fjs.fjs, cross-referenced rather than restated, with the chain-naming detail left to reconcile in that todo. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho --- spec/datajs/README.md | 27 +++++++++++++++++++-------- todo/parser-serializer-restructure.md | 9 ++++++--- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/spec/datajs/README.md b/spec/datajs/README.md index 314d6988f..16c5efb72 100644 --- a/spec/datajs/README.md +++ b/spec/datajs/README.md @@ -523,14 +523,25 @@ Recognized extensions: `.data.js`, `.data.mjs`, `.d.js`, `.d.mjs`. Tools emit **`.data.js`**. Use `.data.mjs` where a file must resolve as an ES module regardless of the enclosing package's `"type"` field. -The media type is **`application/datajs`**, mirroring `application/json`, with -charset UTF-8 implied. - -One practical caveat: that type describes the *data*. A server that expects a -browser to `import` the file must send a JavaScript MIME type (`text/javascript`) -instead, because a module load rejects any other type. The two uses do not -conflict — they are different requests for the same bytes — but a document -served for both needs a deliberate choice. +The media type is **`text/javascript`**, with the format identified out of +band as the dialect **`vnd.fjs.datajs+vnd.fjs.fjs`** — most specific first, so +a consumer that knows only FunctionalScript still reads it correctly. + +DataJS gets no media type of its own, and the reason is not stylistic: RFC 9239 +makes JavaScript MIME types a closed list with no registered `+javascript` +suffix, so `application/datajs` would be opaque to every existing consumer and +would break the one thing a DataJS document is guaranteed to be — a JavaScript +module a browser can `import`. A JSON-shaped format could take +`application/{dialect}+json` and fall back to `application/json`; a +JavaScript-shaped one has no such ladder. + +This follows the dialect design in +[`fjs/todo/group-fs-subdirectories-by-concern.md`](../../fjs/todo/group-fs-subdirectories-by-concern.md), +which settled the question for FunctionalScript's formats generally. That +document names the wider compiler subset's dialect `vnd.fjs.djs`; DataJS is +narrower and takes its own segment, which is the one detail still to reconcile +there — see [that todo](../../fjs/todo/group-fs-subdirectories-by-concern.md) +rather than duplicating the chain rules here. ## Conformance diff --git a/todo/parser-serializer-restructure.md b/todo/parser-serializer-restructure.md index 9d041fbd1..f5c52740e 100644 --- a/todo/parser-serializer-restructure.md +++ b/todo/parser-serializer-restructure.md @@ -239,9 +239,12 @@ throughout. 1. **Spec** — `spec/datajs/`. The specification itself is **done**: [`spec/datajs/README.md`](../spec/datajs/README.md) carries the grammar, data model, const-name exclusions, serialization and normalized form, - the JSON and JavaScript relationships, and the rationale, and proposes - `application/datajs` as the media type (noting that a file served for a - browser `import` must instead be sent as a JavaScript MIME type). The + the JSON and JavaScript relationships, and the rationale, and settles the media + type by deferring to the existing dialect design in + [`fjs/todo/group-fs-subdirectories-by-concern.md`](../fjs/todo/group-fs-subdirectories-by-concern.md): + `text/javascript` with the dialect out of band, since RFC 9239 closes the + JavaScript MIME list. The dialect segment DataJS takes in that chain is the + one detail left to reconcile in that todo. The conformance vectors are the remaining half, tracked in [`spec/datajs/todo/conformance-vectors.md`](../spec/datajs/todo/conformance-vectors.md); stages 3, 4 and 6 consume them. From a1d1ac2ad01611962b299a4ecf17b9197fbf75f7 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 20:45:16 -0700 Subject: [PATCH 366/370] todo: verify local TypeScript version --- fjs/ci/todo/typescript-ci-tool.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 6af65ba6d..2b2239060 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -25,7 +25,7 @@ The Nix package must derive from the CI TypeScript version rather than silently Prefer migrating an environment to Nix before adding a separate npm-global install. The direct-Nix migration is tracked by [65Z-ci-nix](65z-ci-nix.md) and its concrete Node-job implementation [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). A Nix migration must preserve the environment's existing isolation guarantees; in particular, `package-check` must remain a checkout-free packed-package consumer rather than gaining access to repository sources or `tsconfig.json` merely so it can reach a relative flake. If Nix cannot provide the compiler while preserving that isolation, install the CI-pinned TypeScript with npm inside the isolated consumer environment instead. -Local development must continue to support `tsc`, `npm test`, and `npm pack`: outside an environment that provides the compiler, developers install the CI-pinned TypeScript globally with npm so `tsc` is available on `PATH`. +Local development must continue to support `tsc`, `npm test`, and `npm pack`. Outside an environment that provides the compiler, developers install the CI-pinned TypeScript globally with npm so `tsc` is available on `PATH`. Because a global tool is shared across checkouts, repository-owned checks that depend on TypeScript must verify the available compiler version against the current checkout's CI pin before using it. On mismatch, fail fast and print the exact `npm install -g typescript@` command; do not silently run a compiler from another checkout and do not manually install platform dependencies. ### Tasks @@ -38,14 +38,16 @@ Local development must continue to support `tsc`, `npm test`, and `npm pack`: ou - [ ] Add the Nix-packaged TypeScript tool to the generated Node 26 environment (`nodeNixJobs`) so the canonical development shell provides `tsc`; update its proofs/generated-flake expectations. This extends the Node 26 migration tracked by [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). - [ ] Prefer moving npm publishing to Nix first and provide TypeScript there so `prepack` uses the intended compiler during `npm publish`. Coordinate this with [668-ci-npm-publish-workflow](668-ci-npm-publish-workflow.md), which owns generation of the publish workflow. If publishing remains outside Nix, install the CI-pinned compiler with npm rather than manually assembling its files or dependencies. - [ ] Run `tsc` from the environment-provided installation instead of relying on an implicit root `node_modules/.bin/tsc`. +- [ ] Add one repository-owned TypeScript version check derived from the CI pin and reuse it anywhere a checkout can reach an ambient/global `tsc` (including `npm test` and `prepack`/`npm pack`). The check must reject a mismatched compiler before type-checking and tell non-Nix developers to run `npm install -g typescript@`; it must not download or assemble platform dependencies itself. - [ ] Remove `typescript` from the root `package.json` `devDependencies`, then run `npm run update` so `package-lock.json`, `deno.lock`, `bun.lock`, and generated CI files are all regenerated consistently. - [ ] Keep `@types/node` as a devDependency. -- [ ] Update repository-owned developer/check documentation, including `CONTRIBUTING.md`, `AGENTS.md`, `fjs/AGENTS.md`, and `fjs/ci/README.md`: list TypeScript as a developer tool where appropriate, document `npm install -g typescript@` for non-Nix local development, and replace required `npx tsc` instructions with `tsc` where the environment provides it. +- [ ] Update repository-owned developer/check documentation, including `CONTRIBUTING.md`, `AGENTS.md`, `fjs/AGENTS.md`, and `fjs/ci/README.md`: list TypeScript as a developer tool where appropriate, document `npm install -g typescript@` for non-Nix local development, explain that repository checks verify the global compiler against the checkout's CI pin, and replace required `npx tsc` instructions with `tsc` where the environment provides it. - [ ] Update Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks install the CI-pinned TypeScript through Nix or npm, without relying on the root devDependency or manually installing platform dependencies. - [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily and their frozen-lock installs still succeed. - [ ] Verify the generated Node 26 Nix shell provides exactly the CI-configured TypeScript version and can run the canonical type-check/package commands without a local TypeScript devDependency. - [ ] Verify changing the CI TypeScript version does not require changing the Node version or manually updating a platform-package list. - [ ] Verify `package-check` remains checkout-free and uses the CI-configured TypeScript installation without falling back to an unrelated ambient compiler. +- [ ] Verify switching between checkouts with different CI TypeScript pins cannot make `npm test`/`npm pack` silently use the wrong global compiler. - [ ] Verify every environment uses the CI-configured TypeScript version rather than maintaining an independent pin. - [ ] Verify `tsc`, `npm test`, `npm pack`, and the npm publish path work in every environment that is documented or responsible for those checks. From 7c55ba6e7e5277b68aa7fcfcc382f04d9bc89671 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 04:09:15 +0000 Subject: [PATCH 367/370] todo: shorten the heading to what the issue is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `todo/README.md`: "Issue headings should be short and direct — describe the action or the thing, not the full context", and its template is a plain `## Title`. This heading carried a repeat of the filename slug followed by the whole symptom. `Generated declarations shadow their sources` is the thing. The symptom it replaced — a local `npx tsc` passing on an error CI reports — is the first paragraph of `### Problem`, where the context belongs. Sibling files under `fjs/*/todo/` do use a `slug. description` heading, which is what this one was modelled on; the documented rule is the plain title, so the habit is not evidence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- todo/local-tsc-skips-mjs-with-a-generated-declaration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todo/local-tsc-skips-mjs-with-a-generated-declaration.md b/todo/local-tsc-skips-mjs-with-a-generated-declaration.md index 4a77649d9..eadfbe921 100644 --- a/todo/local-tsc-skips-mjs-with-a-generated-declaration.md +++ b/todo/local-tsc-skips-mjs-with-a-generated-declaration.md @@ -1,4 +1,4 @@ -## local-tsc-skips-mjs-with-a-generated-declaration. `npx tsc` passes locally on a `.f.mjs` error CI reports +## Generated declarations shadow their sources **Priority:** P2 **Status:** open From cbbcd26501c70c1298c94c0ae1af83f80ae21789 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 21:16:19 -0700 Subject: [PATCH 368/370] todo: simplify TypeScript CI design --- fjs/ci/todo/typescript-ci-tool.md | 54 ++++++++----------------------- 1 file changed, 14 insertions(+), 40 deletions(-) diff --git a/fjs/ci/todo/typescript-ci-tool.md b/fjs/ci/todo/typescript-ci-tool.md index 2b2239060..6cdce0300 100644 --- a/fjs/ci/todo/typescript-ci-tool.md +++ b/fjs/ci/todo/typescript-ci-tool.md @@ -5,54 +5,28 @@ ### Problem -TypeScript is currently owned by the root `package.json` `devDependencies`. That makes the compiler an implicit dependency of every npm install, including runtime-compatibility jobs that do not type-check, and makes npm package metadata the source of the repository's compiler version. +TypeScript is a development/CI tool, but its version is currently owned by the root `package.json` `devDependencies`. As a result, every npm install gets the compiler, including runtime-compatibility jobs that do not type-check. -TypeScript is a development/CI tool rather than a runtime package dependency. CI already owns versions of development tools and should own the TypeScript pin as well. This also decouples the TypeScript version from the Node/Deno/Bun runtime matrix so CI can change or add compiler-version checks independently of npm dependencies. +### Decision -This is an ownership/decoupling change, not a CI-performance optimization justified by a timing benchmark. Avoiding TypeScript installation in jobs that do not use it is a direct consequence, not the acceptance criterion. The migration must not replace the npm pin with multiple independent compiler pins: every CI/development environment that needs TypeScript should derive the same version from the CI configuration. +The CI tool configuration owns the TypeScript version. Environments that need TypeScript install that version through their normal tool manager: Nix in Nix environments and npm otherwise. Do not reproduce TypeScript's dependency resolution or platform-package selection in FunctionalScript CI code. -Provision tools through installation/package-management tools. npm environments should install the configured TypeScript package with npm; Nix environments should package/install it through Nix's standard npm/package mechanisms. FunctionalScript CI must not reproduce TypeScript's dependency resolver by enumerating, fetching, or wiring platform-specific optional packages such as `@typescript/typescript-*` itself. Platform and transitive dependencies belong to npm/Nix. +Only environments that use TypeScript should install it. This includes the canonical type-checking environment, packed-package validation, npm publishing because `prepack` runs `tsc`, and documented developer environments. Node 22/24, Deno, and Bun runtime jobs should not install TypeScript merely because they install npm dependencies. -This task is intentionally limited to TypeScript. Keep `@types/node` in `devDependencies`. +Repository checks must use the CI-configured TypeScript version rather than an unrelated ambient compiler. The implementation may choose how to expose or validate `tsc`; that mechanism is not part of this design. -### Goal - -Make the CI tool configuration the single repository-owned TypeScript version pin, provision that compiler only in environments that need it through their normal installation tooling, and remove `typescript` from the root `package.json` `devDependencies`. - -Only environments that actually need TypeScript should receive the tool. In particular, Node 22, Node 24, Deno, and Bun jobs should not install TypeScript just because they install npm dependencies. The canonical type-checking job, its generated Node 26 Nix environment, packed-package check, and package publishing path do need the pinned compiler because they invoke `tsc` directly, provide the canonical development toolchain, validate declarations, or invoke it through npm lifecycle scripts such as `prepack`. - -The Nix package must derive from the CI TypeScript version rather than silently taking whatever TypeScript version the pinned Nixpkgs snapshot happens to expose. Use a standard Nix mechanism for packaging an npm package (for example, the appropriate Nix npm-package builder) so Nix/npm resolves and verifies the complete dependency closure, including platform-specific optional dependencies. Any Nix dependency hash or generated package metadata should be produced as part of that standard packaging flow; do not maintain a FunctionalScript-specific list of TypeScript tarballs or platform packages. - -Prefer migrating an environment to Nix before adding a separate npm-global install. The direct-Nix migration is tracked by [65Z-ci-nix](65z-ci-nix.md) and its concrete Node-job implementation [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). A Nix migration must preserve the environment's existing isolation guarantees; in particular, `package-check` must remain a checkout-free packed-package consumer rather than gaining access to repository sources or `tsconfig.json` merely so it can reach a relative flake. If Nix cannot provide the compiler while preserving that isolation, install the CI-pinned TypeScript with npm inside the isolated consumer environment instead. - -Local development must continue to support `tsc`, `npm test`, and `npm pack`. Outside an environment that provides the compiler, developers install the CI-pinned TypeScript globally with npm so `tsc` is available on `PATH`. Because a global tool is shared across checkouts, repository-owned checks that depend on TypeScript must verify the available compiler version against the current checkout's CI pin before using it. On mismatch, fail fast and print the exact `npm install -g typescript@` command; do not silently run a compiler from another checkout and do not manually install platform dependencies. +Keep `@types/node` in `devDependencies`. Preserve the checkout-free isolation of `package-check`. ### Tasks -- [ ] Add the single exact TypeScript version pin to the CI configuration. Do not introduce another repository-owned TypeScript version pin elsewhere. -- [ ] Extend the Nix tool model/generator to install that TypeScript version through a standard Nix npm-package mechanism. Let Nix/npm resolve and verify TypeScript's complete transitive/platform dependency closure; do not fetch or wire TypeScript platform packages manually. -- [ ] Add proofs for the Nix TypeScript tool generation, including version propagation and the generated Node 26 package set. -- [ ] Make the packed-package check read its compiler version from the CI configuration instead of `package.json` so removing `devDependencies.typescript` does not remove `package-check`; update the related proofs for the new pin source. -- [ ] Preserve `package-check` isolation. Prefer Nix only if the compiler can be supplied without checking out the repository or exposing repository `tsconfig.json`, sources, or `node_modules` to the packed-package consumer. Otherwise install `typescript@` with npm inside the isolated consumer environment and expose that installation's `tsc`; npm, not CI code, resolves platform dependencies. -- [ ] Provision the configured TypeScript version in the canonical CI job that runs `tsc` (currently Node 26). -- [ ] Add the Nix-packaged TypeScript tool to the generated Node 26 environment (`nodeNixJobs`) so the canonical development shell provides `tsc`; update its proofs/generated-flake expectations. This extends the Node 26 migration tracked by [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md). -- [ ] Prefer moving npm publishing to Nix first and provide TypeScript there so `prepack` uses the intended compiler during `npm publish`. Coordinate this with [668-ci-npm-publish-workflow](668-ci-npm-publish-workflow.md), which owns generation of the publish workflow. If publishing remains outside Nix, install the CI-pinned compiler with npm rather than manually assembling its files or dependencies. -- [ ] Run `tsc` from the environment-provided installation instead of relying on an implicit root `node_modules/.bin/tsc`. -- [ ] Add one repository-owned TypeScript version check derived from the CI pin and reuse it anywhere a checkout can reach an ambient/global `tsc` (including `npm test` and `prepack`/`npm pack`). The check must reject a mismatched compiler before type-checking and tell non-Nix developers to run `npm install -g typescript@`; it must not download or assemble platform dependencies itself. -- [ ] Remove `typescript` from the root `package.json` `devDependencies`, then run `npm run update` so `package-lock.json`, `deno.lock`, `bun.lock`, and generated CI files are all regenerated consistently. -- [ ] Keep `@types/node` as a devDependency. -- [ ] Update repository-owned developer/check documentation, including `CONTRIBUTING.md`, `AGENTS.md`, `fjs/AGENTS.md`, and `fjs/ci/README.md`: list TypeScript as a developer tool where appropriate, document `npm install -g typescript@` for non-Nix local development, explain that repository checks verify the global compiler against the checkout's CI pin, and replace required `npx tsc` instructions with `tsc` where the environment provides it. -- [ ] Update Docker and OpenAI Codex development setup so their documented `npm test` / `tsc` checks install the CI-pinned TypeScript through Nix or npm, without relying on the root devDependency or manually installing platform dependencies. -- [ ] Verify Node 22, Node 24, Deno, and Bun no longer install TypeScript unnecessarily and their frozen-lock installs still succeed. -- [ ] Verify the generated Node 26 Nix shell provides exactly the CI-configured TypeScript version and can run the canonical type-check/package commands without a local TypeScript devDependency. -- [ ] Verify changing the CI TypeScript version does not require changing the Node version or manually updating a platform-package list. -- [ ] Verify `package-check` remains checkout-free and uses the CI-configured TypeScript installation without falling back to an unrelated ambient compiler. -- [ ] Verify switching between checkouts with different CI TypeScript pins cannot make `npm test`/`npm pack` silently use the wrong global compiler. -- [ ] Verify every environment uses the CI-configured TypeScript version rather than maintaining an independent pin. -- [ ] Verify `tsc`, `npm test`, `npm pack`, and the npm publish path work in every environment that is documented or responsible for those checks. +- [ ] Move the TypeScript version pin to the CI tool configuration and make TypeScript-using jobs derive from it, including `package-check`. +- [ ] Provision TypeScript through Nix/npm in the environments that need it, including Node 26, `package-check`, and npm publishing; preserve `package-check` isolation. +- [ ] Remove `typescript` from the root `devDependencies` and run `npm run update` so npm, Deno, Bun, and generated CI state remain consistent. +- [ ] Update developer documentation and required commands: Nix environments provide TypeScript; non-Nix developers install the CI-pinned version globally with npm; required checks use the environment-provided compiler rather than `npx tsc`. +- [ ] Verify TypeScript is absent from runtime-only jobs and that type-check, pack/package validation, and publish paths use the configured version. ### Related -- [65Z-ci-nix](65z-ci-nix.md) — declarative per-job Nix architecture and direct CI execution. -- [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md) — concrete Node 22/24/26 Nix migration, including the generated Node 26 environment this task extends. -- [668-ci-npm-publish-workflow](668-ci-npm-publish-workflow.md) — generation of the npm publishing workflow; relevant if publishing moves to Nix before the TypeScript dependency is removed. +- [65Z-ci-nix](65z-ci-nix.md) — declarative per-job Nix architecture. +- [66B-dockerfile-nix-integration](66b-dockerfile-nix-integration.md) — Node CI Nix migration. +- [668-ci-npm-publish-workflow](668-ci-npm-publish-workflow.md) — npm publishing workflow generation. From e60a9a9541980ac784b8e0db44e1af6365a2028b Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 21:36:22 -0700 Subject: [PATCH 369/370] ci: allow job-required Nix tools --- fjs/ci/todo/65z-ci-nix.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/fjs/ci/todo/65z-ci-nix.md b/fjs/ci/todo/65z-ci-nix.md index b5c4db7db..f822c49fc 100644 --- a/fjs/ci/todo/65z-ci-nix.md +++ b/fjs/ci/todo/65z-ci-nix.md @@ -65,7 +65,7 @@ Add only the data needed now: - exact package versions copied from that snapshot where native CI needs them; - simple per-job system and package declarations. -For the current jobs, the declarations are: +For the current jobs, the Node runtime declarations are: ```text node22: aarch64-linux, nodejs_22 @@ -73,6 +73,9 @@ node24: aarch64-linux, nodejs_24 node26: aarch64-linux, nodejs_26 ``` +A job may also declare tools required by its own work. Keep those additions job-local; +this TODO does not prescribe which non-Node tools a job needs. + #### Generated environments Generate one self-contained file for each job: @@ -87,7 +90,7 @@ Each generated file should: - pin the exact Nixpkgs commit; - expose `devShells.aarch64-linux.default` for the current ARM Linux job; -- use `pkgs.mkShell` with exactly that job's Node package; +- use `pkgs.mkShell` with that job's declared packages; - be readable without inspecting the generator; - contain no job-selection logic; - contain no unrelated platform branches; @@ -111,7 +114,7 @@ The minimal public contract is: } ``` -The generator substitutes the job's package. If another system is later required, emit +The generator substitutes the job's packages. If another system is later required, emit another explicit `devShells..default` attribute rather than adding a loop or system-selection framework. From ea4eb18f8064ac23e74e029f64c819f9833ac8a7 Mon Sep 17 00:00:00 2001 From: Sergey Shandar Date: Fri, 28 Aug 2026 21:36:42 -0700 Subject: [PATCH 370/370] ci: allow job-required Node tools --- fjs/ci/todo/66b-dockerfile-nix-integration.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/fjs/ci/todo/66b-dockerfile-nix-integration.md b/fjs/ci/todo/66b-dockerfile-nix-integration.md index 61bb9d7d9..cc1c2b21d 100644 --- a/fjs/ci/todo/66b-dockerfile-nix-integration.md +++ b/fjs/ci/todo/66b-dockerfile-nix-integration.md @@ -66,6 +66,7 @@ existing CI config -> generated Node flake.nix -> existing Node job commands - preserve each job's current commands, order, and coverage; - keep `npm run ci-update` Nix-independent and runnable on Windows; - ignore per-job lock files created beside generated flakes; +- let a job add tools required by its own work without changing the Node runtime mapping; - defer generalized shell, cache, and package-provider abstractions until a real requirement appears. @@ -108,7 +109,7 @@ public output: devShells.aarch64-linux.default ``` -The package mapping is explicit: +The Node runtime mapping is explicit: ```text node22 -> pkgs.nodejs_22 @@ -116,7 +117,11 @@ node24 -> pkgs.nodejs_24 node26 -> pkgs.nodejs_26 ``` -Each generated file follows this static shape, with the job's package substituted: +That mapping defines the runtime, not the complete shell. A job may add explicit tools +required by its own work; their owning TODO defines those requirements. + +Each generated file follows this static shape, with the job's declared packages +substituted: ```nix { @@ -204,7 +209,8 @@ node26: ``` The workflow generator should continue supplying current configured versions; the list -above records the existing command families and their order. +above records the existing command families and their order. Other TODOs may change a +job's required tools or commands independently. For each Node job: