mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
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
348 lines
14 KiB
JavaScript
348 lines
14 KiB
JavaScript
import { readFile, writeFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import * as esbuild from 'esbuild'
|
|
import { importTypeScriptModule } from './import-typescript-module.mjs'
|
|
import {
|
|
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
|
TERMINAL_DOCUMENT_MODULE_ORDER,
|
|
TERMINAL_DOCUMENT_RESET_CALL,
|
|
TERMINAL_DOCUMENT_SCOPE_MODULE,
|
|
terminalDocumentStartFunctionName,
|
|
terminalDocumentStopFunctionName
|
|
} from './terminal-document-module-order.mjs'
|
|
|
|
/**
|
|
* Turns one module of the in-WebView terminal document back into the script text the document
|
|
* carries.
|
|
*
|
|
* The document is a string the native WebView loads, so its parts cannot be imported by anything;
|
|
* the web page needs exactly those parts and must not re-implement them. So the parts are modules,
|
|
* and this is the other direction: the modules' declarations, with their imports removed and their
|
|
* exports unmarked, spliced into the one function scope the document has always been.
|
|
*
|
|
* Imports are dropped rather than resolved because inside the document every name is already in
|
|
* scope — that is what the single IIFE means. `document-externals.ts` declares the names that have
|
|
* not moved into modules yet, and it emits nothing at all.
|
|
*
|
|
* `esbuild` does the TypeScript, as it already does for the xterm engine beside this file. It is a
|
|
* transform and not a bundle: a bundler would order the output by its dependency graph, and the
|
|
* document's order is part of what the equivalence test holds fixed.
|
|
*/
|
|
const INDENT = ' '
|
|
|
|
const constantsPath = path.join(
|
|
import.meta.dirname,
|
|
'..',
|
|
'src',
|
|
'terminal',
|
|
'document',
|
|
'document-constants.ts'
|
|
)
|
|
|
|
let substitutions = null
|
|
|
|
/**
|
|
* `document-constants.ts` as the literal text each name stands for.
|
|
*
|
|
* Substitution happens after the import lines are dropped, when the names are free again, and it is
|
|
* textual rather than an esbuild `define` because a `define` whose value is an object or an array
|
|
* is injected as a helper binding instead of being inlined, which is not what the document carries.
|
|
* The names are exported for this purpose only and none of them appears inside a string.
|
|
*/
|
|
async function documentConstantSubstitutions() {
|
|
if (substitutions === null) {
|
|
const module = await importTypeScriptModule(constantsPath)
|
|
substitutions = Object.fromEntries(
|
|
Object.entries(module).map(([name, value]) => [name, JSON.stringify(value)])
|
|
)
|
|
}
|
|
return substitutions
|
|
}
|
|
|
|
/**
|
|
* Replaces each constant's name with its literal.
|
|
*
|
|
* The replacement is a function, not the literal itself: as a string, `$&`, `` $` ``, `$'` and
|
|
* `$n` are replacement patterns, so a constant whose value contains one would be spliced with the
|
|
* match rather than written out. A function replacer has no such reading.
|
|
*/
|
|
export function substituteDocumentConstants(text, substitutions) {
|
|
let substituted = text
|
|
for (const [name, literal] of Object.entries(substitutions)) {
|
|
substituted = substituted.replaceAll(new RegExp(`\\b${name}\\b`, 'g'), () => literal)
|
|
}
|
|
return substituted
|
|
}
|
|
|
|
/**
|
|
* Whether a line is a lint directive.
|
|
*
|
|
* These are removed before the transform, not after it: a directive inside an expression makes
|
|
* esbuild wrap that expression in parentheses to keep the comment where it was, and those
|
|
* parentheses are tokens the document does not have. They are tooling metadata about the source,
|
|
* not part of the program the WebView runs.
|
|
*/
|
|
function isLintDirectiveLine(line) {
|
|
return /^\s*\/\/\s*oxlint-disable/.test(line)
|
|
}
|
|
|
|
/** Whether a line opens an import the document does not need. */
|
|
function isImportLine(line) {
|
|
return /^import[\s{'"]/.test(line)
|
|
}
|
|
|
|
/** Whether a statement that started on this line also ended on it. */
|
|
function closesOnSameLine(line, closer) {
|
|
return line.includes(closer)
|
|
}
|
|
|
|
/**
|
|
* The emitted text of one module: transpiled, unexported, un-imported and indented into the IIFE.
|
|
*
|
|
* Multi-line imports are handled by dropping through to the line that closes them, which esbuild's
|
|
* output makes safe: it prints one import per line.
|
|
*/
|
|
export async function emitTerminalDocumentModule(modulePath) {
|
|
const source = await readFile(modulePath, 'utf8')
|
|
const program = source
|
|
.split('\n')
|
|
.filter((line) => !isLintDirectiveLine(line))
|
|
.join('\n')
|
|
const { code } = await esbuild.transform(program, {
|
|
loader: 'ts',
|
|
format: 'esm',
|
|
target: 'chrome74',
|
|
// The document is read by people as well as by a WebView, and the equivalence test compares
|
|
// tokens, so keeping the printer's own layout costs nothing and keeps the diff legible.
|
|
minify: false
|
|
})
|
|
const kept = []
|
|
// esbuild wraps a long import or export list across lines, so both are skipped to their closer
|
|
// rather than by their first line. An export list dropped by its keyword alone would leave a
|
|
// bare block statement in the document, and an import list would leave its names loose.
|
|
let skipUntil = null
|
|
for (const line of code.split('\n')) {
|
|
if (skipUntil !== null) {
|
|
if (closesOnSameLine(line, skipUntil)) {
|
|
skipUntil = null
|
|
}
|
|
continue
|
|
}
|
|
if (isImportLine(line)) {
|
|
skipUntil = closesOnSameLine(line, ' from ') || closesOnSameLine(line, ';') ? null : ' from '
|
|
continue
|
|
}
|
|
if (line.startsWith('export {')) {
|
|
skipUntil = closesOnSameLine(line, '}') ? null : '}'
|
|
continue
|
|
}
|
|
kept.push(line.startsWith('export ') ? line.slice('export '.length) : line)
|
|
}
|
|
const text = substituteDocumentConstants(kept.join('\n'), await documentConstantSubstitutions())
|
|
const substituted = await esbuild.transform(text, {
|
|
loader: 'js',
|
|
format: 'esm',
|
|
target: 'chrome74',
|
|
minify: false
|
|
})
|
|
const body = substituted.code.trim()
|
|
return body
|
|
.split('\n')
|
|
.map((line) => (line.length === 0 ? line : `${INDENT}${line}`))
|
|
.join('\n')
|
|
}
|
|
|
|
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,
|
|
'..',
|
|
'src',
|
|
'terminal',
|
|
'terminal-webview-document-script.generated.ts'
|
|
)
|
|
|
|
/**
|
|
* The start functions the emitted document calls, in module order (ruling 20).
|
|
*
|
|
* Presence is read from the source rather than listed here: a module that has no top-level effect
|
|
* exports no start function, and one that grows an effect is reached the moment it does. The
|
|
* declaration is matched on its own line because that is how esbuild's TypeScript prints it and
|
|
* how every module in this directory writes it.
|
|
*/
|
|
export async function terminalDocumentStartCalls(moduleNames) {
|
|
return await declaredFunctions(moduleNames, terminalDocumentStartFunctionName)
|
|
}
|
|
|
|
/** The stop functions, in module order. The page runs them in reverse; the WebView never stops. */
|
|
export async function terminalDocumentStopCalls(moduleNames) {
|
|
return await declaredFunctions(moduleNames, terminalDocumentStopFunctionName)
|
|
}
|
|
|
|
async function declaredFunctions(moduleNames, nameFor) {
|
|
const found = []
|
|
for (const name of moduleNames) {
|
|
const source = await readFile(path.join(documentDirectory, `${name}.ts`), 'utf8')
|
|
const declared = nameFor(name)
|
|
if (new RegExp(`^export function ${declared}\\(\\) \\{$`, 'm').test(source)) {
|
|
found.push(declared)
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
/** The name the emitted factory is declared under, and the one the native document calls. */
|
|
export const TERMINAL_DOCUMENT_FACTORY_NAME = 'createTerminalDocument'
|
|
|
|
/** The name the factory's host argument is bound to, which is the scope's only input. */
|
|
const HOST_PARAMETER = 'host'
|
|
|
|
/**
|
|
* The emitted scope construction, which is the one line the host argument reaches.
|
|
*
|
|
* The module declares `scope` for its own consumers, who have no host to pass; the document has
|
|
* one, and it is the factory's parameter. So the emitted declaration is rewritten rather than
|
|
* written twice, and the rewrite refuses if the line it expects is not there — a rename that
|
|
* silently dropped the host would give every call the window defaults.
|
|
*/
|
|
export function bindScopeToHost(text) {
|
|
const declaration = 'const scope = createTerminalDocumentScope();'
|
|
const occurrences = text.split(declaration).length - 1
|
|
if (occurrences !== 1) {
|
|
throw new Error(
|
|
`expected exactly one \`${declaration}\` in the emitted scope module, found ${String(occurrences)}`
|
|
)
|
|
}
|
|
return text.replace(declaration, `const scope = createTerminalDocumentScope(${HOST_PARAMETER});`)
|
|
}
|
|
|
|
/**
|
|
* One module's text exactly as the emitted document carries it.
|
|
*
|
|
* The scope module is the only one the document rewrites, so a reader that compared a raw emit
|
|
* against the document would miss by that one line. Exported so every reader applies the same
|
|
* rewrite rather than restating it and agreeing with a document that was built differently.
|
|
*/
|
|
export async function emitDocumentedTerminalModule(moduleName) {
|
|
const text = await emitTerminalDocumentModule(path.join(documentDirectory, `${moduleName}.ts`))
|
|
return moduleName === TERMINAL_DOCUMENT_SCOPE_MODULE ? bindScopeToHost(text) : text
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* state by construction, so the page needs no module singleton, no reset between mounts and no
|
|
* 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 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
|
|
// the hand-written document did not have, and the host seams come ahead of it because its
|
|
// defaults are those six functions.
|
|
const order = [
|
|
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
|
|
TERMINAL_DOCUMENT_SCOPE_MODULE,
|
|
...TERMINAL_DOCUMENT_MODULE_ORDER
|
|
]
|
|
for (const name of order) {
|
|
emitted.push(await emitDocumentedTerminalModule(name))
|
|
}
|
|
// Rulings 20 and 21: the modules above only declare. The scope's reset comes first, so the
|
|
// state every module reads is the state a fresh parse has; then every element read, listener
|
|
// and reporter install runs, once here and per mount on the page, in the order both hosts share.
|
|
const calls = [TERMINAL_DOCUMENT_RESET_CALL, ...(await terminalDocumentStartCalls(order))].map(
|
|
(name) => `${INDENT}${name}();`
|
|
)
|
|
// Ruling 21's cancellation, in reverse module order: a stop undoes what its own start did, and
|
|
// the frames the document is still owed go last because the stops above may schedule nothing
|
|
// more. `stop` is what the page's dispose calls; the WebView never calls it.
|
|
const stops = (await terminalDocumentStopCalls(order))
|
|
.toReversed()
|
|
.map((name) => `${INDENT}${INDENT}${name}();`)
|
|
const stopBody = [
|
|
`${INDENT}function stop() {`,
|
|
...stops,
|
|
`${INDENT}${INDENT}cancelDocumentFrames();`,
|
|
`${INDENT}}`
|
|
]
|
|
return [
|
|
...emitted,
|
|
...calls,
|
|
...stopBody,
|
|
`${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_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) {
|
|
await main()
|
|
}
|