build(mobile): emit the page's terminal document factory beside the WebView's

Ruling 23, and the first half of C7.5b commit 2: the artifact the page will
import. The page cannot run the native script, because building a function from a
string needs `eval` and the page's policy refuses it, and it cannot run the
modules either, because they are one singleton while the whole point of the
factory is a scope per call. So one emitted body gets two wrappers.

`buildTerminalDocumentFactoryBody` is now the shared half: the modules in order,
the start sequence, the stop handle and the return. The native script wraps it in
the declaration and the trailing call, exactly as before. The new
`terminal-webview-document-factory.generated.ts` wraps the same lines in a
`@ts-nocheck` module whose only other content is the type import and the
annotated signature. One generator run writes both, so the page's factory cannot
be a build behind the WebView's.

`@ts-nocheck` covers this one generated file. Every line of its body is esbuild
output from a module that was type-checked at its source, with `declare global`
blocks and type re-exports already erased and constants already substituted; the
one line a caller reads is the signature, and the generator writes it with its
types. `TerminalDocument` joins `TerminalDocumentHost` in `document-host-seams.ts`
as the shape the factory returns.

The pin is byte equality. `document-factory-artifacts.test.ts` strips each
wrapper and holds the remaining text equal, so the byte golden pins the page's
artifact by construction rather than by a second golden; it also reads the file on
disk against what the generator would write now, since that file is gitignored and
built by postinstall, and it refuses a trailing call in the page's copy, which
would start a document as the module was imported.

The path joins `.gitignore` and the oxlint ignore list beside the engine artifact.
The consumer census gains the generated file by name: it is the document, and its
one import is the host contract its signature is written against.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-20 19:05:58 -04:00
parent eb902c5b9f
commit 587000852f
6 changed files with 144 additions and 11 deletions
+1
View File
@@ -2,6 +2,7 @@ node_modules/
src/terminal/terminal-webview-engine.generated.ts
src/terminal/terminal-webview-engine-css.generated.ts
src/terminal/terminal-webview-document-script.generated.ts
src/terminal/terminal-webview-document-factory.generated.ts
src/components/pr-sidebar/mermaid-webview-engine.generated.ts
.expo/
dist/
+4 -1
View File
@@ -1,7 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["../.oxlintrc.json"],
"ignorePatterns": ["src/terminal/terminal-webview-engine.generated.ts"],
"ignorePatterns": [
"src/terminal/terminal-webview-engine.generated.ts",
"src/terminal/terminal-webview-document-factory.generated.ts"
],
"rules": {
"react-hooks/exhaustive-deps": "off",
"react/no-unescaped-entities": "off",
@@ -154,6 +154,19 @@ export async function emitTerminalDocumentModule(modulePath) {
const documentDirectory = path.join(import.meta.dirname, '..', 'src', 'terminal', 'document')
const GENERATED_HEADER =
`// Generated by scripts/build-terminal-document-script.mjs. Do not edit.\n` +
`// The source is mobile/src/terminal/document/, in the order\n` +
`// scripts/terminal-document-module-order.mjs pins.`
export const TERMINAL_DOCUMENT_FACTORY_MODULE_PATH = path.join(
import.meta.dirname,
'..',
'src',
'terminal',
'terminal-webview-document-factory.generated.ts'
)
export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join(
import.meta.dirname,
'..',
@@ -229,8 +242,8 @@ export async function emitDocumentedTerminalModule(moduleName) {
}
/**
* The document's whole script: one factory whose body is every module in the order the document
* had, the call sequence that starts them, and the handle that stops them again.
* The factory's body: every module in the order the document had, the call sequence that starts
* them, the handle that stops them again, and the return. Shared by both artifacts (ruling 23).
*
* Ruling 22. The concatenation already gave the modules one function scope with one local `scope`;
* naming that scope a function is what makes it the shape both hosts run. Every call gets its own
@@ -238,7 +251,7 @@ export async function emitDocumentedTerminalModule(moduleName) {
* claim on the page — a second terminal is a second call. The native document is this function and
* one call with no argument, which is what it has always been.
*/
export async function buildTerminalDocumentScript() {
export async function buildTerminalDocumentFactoryBody() {
const emitted = []
// The scope object goes first: every module below reads it, and the document is one function
// scope, so it has to exist before any of them run. It is the only part of the emitted script
@@ -271,25 +284,62 @@ export async function buildTerminalDocumentScript() {
`${INDENT}}`
]
return [
`function ${TERMINAL_DOCUMENT_FACTORY_NAME}(${HOST_PARAMETER}) {`,
...emitted,
...calls,
...stopBody,
`${INDENT}return { send: handleMsg, stop: stop };`,
`${INDENT}return { send: handleMsg, stop: stop };`
].join('\n')
}
/** The document's script, as the native WebView carries it: the factory, then the one call. */
export async function buildTerminalDocumentScript() {
const body = await buildTerminalDocumentFactoryBody()
return [
`function ${TERMINAL_DOCUMENT_FACTORY_NAME}(${HOST_PARAMETER}) {`,
body,
`}`,
`${TERMINAL_DOCUMENT_FACTORY_NAME}();`
].join('\n')
}
/**
* The same factory, as a module the page imports.
*
* Ruling 23: one emitted body, two wrappers. The page cannot run the native script — building a
* function from a string needs `eval`, which its policy refuses — and it cannot run the modules
* either, because they are one singleton and the whole point of the factory is a scope per call.
* So it imports this, whose body is the native factory's body line for line; the artifact test
* holds the two equal, which is how the byte golden ends up pinning this file too.
*
* `@ts-nocheck` covers exactly one generated file. Every line below is esbuild output from a module
* that was type-checked at its source, with its `declare global` blocks and type re-exports already
* erased and its constants already substituted; the one line a caller reads is the signature, and
* the generator writes that with its types.
*/
export async function buildTerminalDocumentFactoryModule() {
const body = await buildTerminalDocumentFactoryBody()
return [
GENERATED_HEADER,
'// @ts-nocheck -- ruling 23: the body is emitted text, type-checked at each source module.',
`import type { TerminalDocument, TerminalDocumentHost } from './document/document-host-seams'`,
'',
`export function ${TERMINAL_DOCUMENT_FACTORY_NAME}(`,
`${INDENT}${HOST_PARAMETER}: TerminalDocumentHost`,
`): TerminalDocument {`,
body,
`}`,
''
].join('\n')
}
async function main() {
const script = await buildTerminalDocumentScript()
await writeFile(
TERMINAL_DOCUMENT_SCRIPT_PATH,
`// Generated by scripts/build-terminal-document-script.mjs. Do not edit.\n` +
`// The source is mobile/src/terminal/document/, in the order\n` +
`// scripts/terminal-document-module-order.mjs pins.\n` +
`export const TERMINAL_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n`
`${GENERATED_HEADER}\nexport const TERMINAL_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n`
)
// One run writes both, so the page's factory can never be a build behind the WebView's.
await writeFile(TERMINAL_DOCUMENT_FACTORY_MODULE_PATH, await buildTerminalDocumentFactoryModule())
}
if (process.argv[1] === import.meta.filename) {
@@ -0,0 +1,64 @@
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
import {
buildTerminalDocumentFactoryBody,
buildTerminalDocumentFactoryModule,
buildTerminalDocumentScript,
TERMINAL_DOCUMENT_FACTORY_MODULE_PATH,
TERMINAL_DOCUMENT_FACTORY_NAME
} from '../../../scripts/build-terminal-document-script.mjs'
/**
* The two artifacts the generator writes, held to one body (ruling 23).
*
* The WebView gets a string it loads; the page gets a module it imports, because building a
* function from that string needs `eval` and the page's policy refuses it. Two files is the cost of
* that, and the risk is the obvious one: they drift, and the terminal on the page stops being the
* terminal on the phone while every other test stays green. So the wrappers are stripped and the
* remainder compared byte for byte, which is also what makes the byte golden pin the page's file.
*/
/** What is left of the native script once its declaration line, closing brace and call are gone. */
function nativeFactoryBody(script: string): string {
const open = `function ${TERMINAL_DOCUMENT_FACTORY_NAME}(host) {\n`
const close = `\n}\n${TERMINAL_DOCUMENT_FACTORY_NAME}();`
expect(script.startsWith(open), 'the native script opens with the factory').toBe(true)
expect(script.endsWith(close), 'the native script ends with the closing brace and the call').toBe(
true
)
return script.slice(open.length, script.length - close.length)
}
/** What is left of the page module once its header, directive, import and signature are gone. */
function pageFactoryBody(module: string): string {
const open = `): TerminalDocument {\n`
const at = module.indexOf(open)
expect(at, 'the page module declares the annotated signature').toBeGreaterThan(0)
const close = '\n}\n'
expect(module.endsWith(close), 'the page module ends with the closing brace').toBe(true)
return module.slice(at + open.length, module.length - close.length)
}
describe('the two terminal document artifacts', () => {
it('carry the same factory body, byte for byte', async () => {
const body = await buildTerminalDocumentFactoryBody()
expect(nativeFactoryBody(await buildTerminalDocumentScript())).toBe(body)
expect(pageFactoryBody(await buildTerminalDocumentFactoryModule())).toBe(body)
})
it('is on disk as the generator would write it now', async () => {
// The page's file is gitignored and built by postinstall, so a tree whose modules moved after
// the last build would import yesterday's document. The native script's staleness is already
// caught by the byte golden; this is the same reading for the file beside it.
const onDisk = await readFile(TERMINAL_DOCUMENT_FACTORY_MODULE_PATH, 'utf8')
expect(onDisk).toBe(await buildTerminalDocumentFactoryModule())
})
it('gives the page a factory it can call and no call of its own', async () => {
// A trailing call would start a document as the module was imported, which is the parse-time
// work ruling 20 removed — and on the page it would run before any host element existed.
const module = await buildTerminalDocumentFactoryModule()
expect(module).toContain(`export function ${TERMINAL_DOCUMENT_FACTORY_NAME}(`)
expect(module).not.toContain(`\n${TERMINAL_DOCUMENT_FACTORY_NAME}();`)
})
})
@@ -56,6 +56,18 @@ export type TerminalDocumentHostSeams = {
*/
export type TerminalDocumentHost = Partial<TerminalDocumentHostSeams>
/**
* A running document: the two things a host can do to one it has started.
*
* `send` is the router the WebView already reached through its message listener, which the page
* calls directly. `stop` runs every module's stop and takes back the frames the document is owed;
* the page's dispose calls it, and the WebView never does.
*/
export type TerminalDocument = {
send: (message: Record<string, unknown>) => void
stop: () => void
}
declare global {
interface Window {
ReactNativeWebView?: { postMessage: (message: string) => void }
@@ -25,7 +25,10 @@ const ALLOWED_INSIDE_TERMINAL = new Set([
'terminal-webview-html.ts',
'terminal-webview-html.web.ts',
// Test scaffolding that runs the WebView's own document text; it is not shipped in either build.
'terminal-webview-mouse-test-harness.ts'
'terminal-webview-mouse-test-harness.ts',
// Generated, and it *is* the document: its body is the modules' own emitted text (ruling 23).
// Its one import is the host contract the signature is written against.
'terminal-webview-document-factory.generated.ts'
])
const FORBIDDEN_ABOVE_THE_CONTRACT = [