mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* test(mobile): pin the terminal WebView document byte for byte
The document is already pinned as a digest, which says whether the emitted
bytes moved and nothing about where. C7.1 moves the hand-written script inside
it into modules the web page can import and rebuilds the document from them,
and the claim that has to hold through every one of those commits is that the
native screen kept the document it had. A digest cannot be the instrument for
that: it fails as two hexadecimal strings.
So the document is also committed as itself. The fixture is generated by
`scripts/build-terminal-document-fixture.mjs`, never pasted, and the test
rebuilds the comparison through that script's own substitution rather than
restating it, so a fixture written by one rule and read by another cannot agree
with itself.
The generated xterm engine is stored as two placeholders. It is already covered
by the digest test, postinstall regenerates it from whatever xterm the lockfile
holds, and inlining it would put 612 KiB of vendored bytes into the file whose
job is to isolate hand-written changes. Two further cases keep that from
becoming a hole: the placeholders must each appear exactly once and the engine
must not appear at all, and the restored document must equal the real one.
Regenerating the fixture is a review event. It is only correct when the emitted
document was meant to change, and the diff in that commit is the evidence.
Red-first: flipping one character inside a comment in `write-queue.ts` fails
both identity cases with a one-line diff naming the comment, where the digest
test reports a hash.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare two terminal documents as programs, not as bytes
The C7.1 flip commit moves the document's 57 reassigned variables onto a scope
object, because a variable assigned across ES modules is a syntax error, and
every read and write of them gains a qualifier. The ruling asks that the review
of that commit be a test rather than a 515-line read. This is that test's
instrument.
It cannot be a byte comparison. Once the script's source is modules, `oxfmt`
owns its style, and the repository's style has no semicolons where the
hand-written document has one on nearly every line. A byte diff would therefore
be dominated by changes that are not the refactor, which is the opposite of
what the reviewer needs.
So the comparison is over tokens: semicolons are excluded for the same reason
they moved, comments never reach the stream, and one difference is allowed —
`name` becoming `<qualifier>.name`, three tokens for one — which it counts and
reports. It is stricter than "it still runs": a reordered statement, a changed
literal, a dropped operator, a renamed local and a qualifier under the wrong
object name all diverge, each reported with the token index and both sides.
Acorn carries `value` on its tokens but does not declare it, so the field is
read through a narrowing check rather than asserted onto the declared type.
Red-first, by mutation: dropping the qualifier-name check fails the case that
names it; removing the leftover-token check fails the dropped- and
added-statement cases; treating semicolons as significant fails the three cases
that depend on ignoring them. The acceptance case runs on the real 2,758-line
script rather than on a fixture, so the instrument is known to survive
everything the document actually contains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): count each normalisation the move makes, separately
Measured while extracting the first group: the document's ES5 style is not a
style this repository's own rules permit. `curly` braces 279 brace-less
if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446
`var` declarators become `const`, `let` or a scope field. Those rewrites land
before the qualifier is considered at all, so "the qualifier and nothing else"
was never reachable once the source is a linted module.
The comparison now allows exactly four classes and counts each on its own: a
reference that gained the qualifier, a declaration that moved onto the scope
object, a `var` that only changed keyword, a body that gained braces, and a
catch clause that lost its binding. Separate counters rather than a total,
because the flip commit pins each number and a total would let one class absorb
another — which is the drift the pin exists to catch. The two `var` classes
partition the 446, and the qualifier's 641 sites partition into references that
kept their declaration and declarations that moved.
Two ordering facts the cases pin. The catch rule is tried before the brace rule,
or the inserted-brace rule eats the `{` that follows `catch` and the streams
never resynchronise. A body braced at the very end leaves its closing brace
after the baseline has run out, so trailing closes are absorbed after the walk
rather than reported as a length difference.
Everything outside the four classes still refuses with the token index and both
sides: a changed literal, a dropped operator, a reordered pair, a renamed local,
a qualifier under another object's name, a brace opened and never closed, and a
brace closed where none was opened.
Red-first, by mutation: disabling the catch rule, disabling the trailing-brace
absorption, folding scope-field declarations into plain references, and not
counting brace insertions each fail exactly the case that covers them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the mouse-report cell a module the page can import
The first of the twelve groups the document already names. `*-injected.ts` has
been splicing JS strings into the document for a while, and tests evaluate
those strings, so the one-source-two-consumers shape is already there; what is
missing is that a string cannot be imported by the web page, typechecked, or
linted. This turns one of them into a module and adds the generator that puts
it back into the document.
The generator is a transform, not a bundle: a bundler orders its output by the
dependency graph, and the document's order is part of what the equivalence test
holds fixed. Imports are dropped rather than resolved, because inside the
document every name is already in scope — that is what the single IIFE means —
and `document-externals.ts` declares the names whose groups have not moved yet
and emits nothing at all. esbuild prints an ESM module's exports as a trailing
block, so that block is dropped whole rather than by its keyword; leaving the
keyword behind would put a bare block statement in the document.
Both sides of the comparison now go through that same printer before being
read. Otherwise every choice the printer makes — semicolons, property
shorthand, quote style — reads as a difference in the program when it is a
difference in who typed it, and each would need its own rule. A script that
does not parse is reported as a refusal naming its side, not thrown.
`let` is contextual outside strict mode, so acorn reports it as a name and not
as a keyword; without that the var-to-let rewrite the linter performs would be
refused on every reassigned local.
The group's counts are pinned exactly: nine references gained the qualifier
(`term` seven times, `panX` and `panY` once each), nine locals became `const`
or `let`, thirteen one-statement `if` bodies gained braces, no declaration
moved onto the scope object and no catch clause lost a binding.
The document is untouched, so the byte pin from 3006d8dfdf is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the query-reply gate a module the page can import
The second of the twelve groups, and the one that corrects the scope table's
membership rule.
`terminalDataRepliesEnabled` is written from four places, so the whole-script
census counted it among the 57 variables that cannot stay free across modules.
All four writes are in this group. Once the script is modules, a variable
written only inside the module that declares it is that module's own state, not
the document's, and it stays a `let` there. So the scope object holds what
crosses a module boundary, and the 57 is an upper bound rather than the answer;
the qualifier count the flip commit pins will be lower than the 641 measured
over the single scope, and by how much is a function of where the boundaries
fall.
Two references do cross here and are qualified: the write-queue generation this
group compares against, and the observer-disposal list it pushes onto.
Counts pinned: two qualified references, one `var` to `let`, two one-statement
`if` bodies braced, both `catch (e) {}` clauses unbound, no declaration moved.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make reflow a module, and give the generator its own tests
The third group, and the defect it found: esbuild wraps a long import list
across lines, and the generator was skipping only the first of them, which left
the remaining names loose in the emitted script. The document did not parse, and
the equivalence check said so by name rather than throwing — which is what that
refusal path was added for. Both lists, import and export, are now skipped to
their closer instead of by their first line.
The generator's own tests cover what the per-group comparisons cannot say on
their own: an export is unmarked and indented into the document scope, a
one-line import is dropped, a wrapped import is dropped whole, the trailing
export block esbuild prints is dropped rather than left as a bare block
statement, and types are erased without touching the program.
Reflow's counts: eleven qualified references — the terminal ten times and the
settled row count once — six locals that became `const`, and the two early
returns braced. The row count is written from three groups, so unlike the
query-reply flag it is the document's state rather than one module's.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the keyboard-avoidance metrics a module
The fourth group, and the first that needed a non-null assertion.
`lineHasVisibleContent` reads the terminal's column count with no guard of its
own; the guard is in `computeContentBottomRow`, which is its only caller. Adding
a guard would change the program, and optional chaining would change what
happens when there is no terminal — the document throws there today. TypeScript
erases a non-null assertion, so the emitted script is unchanged and the
invariant is written down where the reader needs it.
Reflow now imports the metrics call from this module rather than declaring it an
external, which is the shape every group takes as its neighbours arrive.
Counts: fourteen qualified references, nine locals rebound, ten one-statement
bodies braced, and the two `catch (e) {}` clauses — the row scan and the
alternate-screen probe — unbound.
The scope table's rule is stated more precisely with it: a variable is this
module's own only when the group both declares and assigns it. While the rest of
the document is still strings, one the main slice declares stays shared even if
every use is in one group, because emitting a second declaration beside the one
the slice still carries would not be the same program.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make WebGL loss recovery a module
The fifth group, and the first carrying a top-level statement rather than only
declarations: the visibility listener it registers. In the document that runs
when the IIFE reaches it; as a module it runs on import, which is the same
single registration.
The context-loss listener disposes the addon it is registered on, so it cannot
run before that addon exists, but the assignment is to a `let` a closure
captures and TypeScript will not carry the narrowing across it. A non-null
assertion, erased by the compiler, keeps the emitted script identical and puts
the invariant where the reader is.
Counts: twenty-three qualified references across the terminal, the addon, its
retry timer and the theme the host last sent; three locals rebound; twelve
one-statement bodies braced; five of the six catch clauses unbound, the sixth
keeping its binding because the attach failure reads the error into its
diagnostic.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make indirect-pointer scroll a module, and count a fifth class
The sixth group found a rule the four classes do not cover, so I measured the
whole script rather than meeting them one at a time: linting all 2,757 lines as
a module trips `curly` 279 times and `no-unused-vars` 38, both already counted,
and then five further rules at 23 sites — `prefer-number-properties` 17,
`prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1
and `no-unused-expressions` 1.
Seventeen of those 23 are one rewrite: a global numeric function moved onto
`Number`. It has the same token shape as the qualifier, so it is counted as its
own class rather than folded into anything, and only the four numeric globals
are admitted — anything else appearing under `Number` is refused, which a case
pins. Every site is already behind a `typeof … === 'number'` check or is parsing
a string, so the two forms are the same test.
The remaining six sites are each a different shape and too few to be worth
matching; they will surface as refusals in whichever group carries them, and I
will report each rather than widen this.
The scroll accumulator is the first declaration to move onto the scope: it is
declared in this group but a touch scroll in another slice resets it, so the
`var` becomes an assignment to the shared field and the class that exists for
exactly that counts one.
Counts: five qualified references, one declaration moved, four locals rebound,
eight bodies braced, one `Number` rewrite.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal surface-swap group into a module
The seventh named group. `surface` and the uncommitted terminal are read by
other slices, so both move onto the scope; the two committed handles and the
pending surface are declared and assigned only here and stay module locals.
Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2,
unbound catches 2, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): substitute build-time constants into the emitted document
The document's script text is not all hand-written: parts of it are template
literals interpolating real values, starting with the theme background. A
module cannot interpolate and still be the same program, so the generator now
derives an esbuild `define` from `document-constants.ts` and substitutes after
the import lines are dropped, when the names are free again. The page imports
the very same bindings, so there is one source either way.
The fixture script's TypeScript loader moves beside it rather than being
written twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal theme group into a module
The eighth named group, and the first parameterised one: its background
fallback comes from the mobile theme through `document-constants.ts`.
Two sites carry a line-scoped lint disable rather than the rewrite the rule
asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every
normalisation class the equivalence instrument counts, so taking them would
change the program the native document carries, which is the one thing this
branch holds fixed. The reason is on the disable line.
Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13,
unbound catches 0, number properties 9.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal path-tap group into a module
The ninth named group, and a pure query: it reads no shared state, so it has
no qualifier sites at all.
Two things this group forced. The generator now drops lint directive lines
before the transform, because a directive inside an expression makes esbuild
parenthesise that expression to keep the comment where it was, and those
parentheses are tokens the document does not have. And the two regexes keep
their `no-useless-escape` escapes behind a line-scoped disable, for the same
reason the theme group keeps `Math.pow`.
One name the document declares twice in one function stays `var`. Two
block-scoped declarations would be two bindings where the document has one,
and esbuild renames the inner one to say so.
Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20,
unbound catches 0, number properties 2.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal tap-dispatch group into a module
The tenth named group, and the heaviest reader of shared state: the selection,
its elements, its thresholds and both press origins are all declared by the
overlay slice, which is still document text, so all of them move onto the
scope with their declarations left where they are.
Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal mouse-click-drag group into a module
The eleventh named group. The escape byte and both SGR mouse modes join the
scope from the runtime slice; the gesture itself is declared here and never
read outside, so it stays a module local.
Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal url-tap group into three modules
The twelfth and last named group, and the second parameterised one: both
candidate patterns and the length bound come through `document-constants.ts`.
Three modules rather than one. At 303 lines it was over the file cap, and the
document's own order interleaves the OSC 8 lookup with the file-URL parsing,
so the split follows that order and the group's text is the three emissions
joined. The test does the joining.
Note for a later lane: `terminal-webview-url-tap.ts` and
`terminal-file-url-tap.ts` already hold TypeScript twins of some of this,
written for the React Native side and not identical to what the document
carries. Collapsing the two is a behaviour change and does not belong in a
branch whose whole claim is that the document did not move.
Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25,
unbound catches 6, number properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-mode DECSET scan slice into a module
The first of the thirteen inline slices. Both control-sequence introducers,
the straddling scan tail and all three mode fields are declared by the
runtime-state slice, which is still document text, so they move onto the scope
with their declarations left where they are.
Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal message-bridge slice into a module
The script and the document end in the same slice, so the slice splits in two
at the point where the IIFE closes: the script half becomes a module, the
document half stays text. The byte pin proves the join is unchanged.
The second catch keeps its binding: it names the error and reports it.
Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): give the document close its own slice file
The previous commit put two exports in one slice file, which the slice-count
guard reads as a mismatch: it derives the slice list from the composer's
imports and cross-checks it against the composed entries, one per file. Five
suites failed to load.
Splitting the file rather than the constant is the better shape anyway. The
file was called `message-bridge-and-document-close` because it carried two
concerns; now each has its own.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal term-observers slice into modules
This slice interpolates the already-extracted keyboard-avoidance group between
its own two halves, so its text is three emissions joined in that order and
the test does the joining.
A sixth normalisation class, measured here rather than assumed: the printer
writes `{ name: name }` back as shorthand, and qualifying the value makes the
property name unavoidable again, so one baseline token faces four. It is
counted on its own like the others, with its own acceptance case in the
instrument's test, and every existing group's pin now carries a zero for it.
Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12,
unbound catches 6, number properties 0, shorthand properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-state-and-eviction slice into a module
The slice that declares most of the shared selection state: every threshold,
every overlay element and the selection itself, twenty-two scope declarations
in one place. The eviction counter is declared and assigned only here, so it
stays a module local.
Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the smooth-scroll and cell-geometry slice
Two modules, not one: the slice carries the normal-buffer smooth scroll and
then the cell-to-pixel geometry, and the split follows that order so the
group's text is the two emissions joined. Four names stop being externals and
become real imports.
Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal write-queue slice into a module
The slice also carries `disposeTermObservers` and `extractMouseModeScanTail`,
which belong to other concerns but sit here because emitted-document order
pins them here; four names stop being externals as a result.
The observer disposal keeps its guard-as-expression form behind a line-scoped
disable: the rewrite the rule asks for is outside every counted class.
Counts: qualified 50, scope declarations 0, rebindings 11, braced bodies 10,
unbound catches 1, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal fit-scale slice into a module
The slice opens with the already-extracted theme group, so its text is two
emissions joined. Four more names stop being externals.
Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20,
unbound catches 0, number properties 9, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal init-and-write slice into a module
The slice opens with the already-extracted webgl-recovery group, so its text
is two emissions joined. init() resets almost every field the document shares,
which makes this the densest qualifier site in the script.
The caret options were interpolated from the theme module, so they join
`document-constants.ts` as four exports: a substitution is keyed by name, not
by property path.
One local the document declares and never reads keeps a line-scoped
`no-unused-vars` disable. Removing it would be a different program, which is
the one thing this branch does not do.
Counts: qualified 83, scope declarations 0, rebindings 11, braced bodies 18,
unbound catches 7, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the runtime-state and text-scaling slice
The document's declaration block, where almost everything it shares is
declared, with the query-reply and surface-swap groups interpolated inside it.
Three modules: the two declarations that come before the groups, the text
scaling, and the viewport transform with the scroll indicator. Seven more
names stop being externals.
Two things this slice forced.
The scope-declaration rule now counts each declarator of one `var`, because
`var panX = 0, panY = 0` becomes two assignments onto the scope. It has its
own acceptance case in the instrument's test.
The two halves are compared against their own text rather than as one joined
program. The declaration the slice opens with is shadowed by a parameter
inside one of the interpolated groups, and printing the baseline as one
program renames that parameter; qualifying the outer name removes the shadow,
so the rename has nothing to correspond to. Splitting the slice on the group
constants compares like with like, and those groups have their own tests.
Build-time constants are now substituted textually rather than through an
esbuild `define`: a `define` whose value is an object or an array is injected
as a helper binding instead of being inlined.
Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38,
rebindings 25, braced bodies 13, unbound catches 1.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): format the two test files the last commit left unformatted
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-report and scroll-routing slice
Two modules around the already-extracted mouse-report-cell group: the viewport
cell lookup that precedes it, and the mouse input encoding and scroll routing
that follow. Eight more names stop being externals, which leaves ten.
Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42,
unbound catches 3, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the host-message-router slice into modules
Two modules after the already-extracted reflow group: the postMessage bridge
with the engine error reporting that rides on it, and the router itself.
`notify`, `handleMsg` and `reportEngineError` stop being externals, which
leaves seven.
The catch binding handed to the error reporter keeps a cast: a catch variable
is `unknown` under strict mode, and the reporter reads only `message` before
falling back to `String()`. The reason is on the line.
Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12,
unbound catches 2, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-overlay slice into modules
Two modules after the already-extracted path-tap and url-tap groups: the
selection range with the xterm mirror, and the overlay positioning with the
edge scroll. Six more names stop being externals, which leaves one.
Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63,
unbound catches 9, number properties 6, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the surface-touch-gestures slice into modules
The last of the thirteen slices. Two modules after the three already-extracted
groups: the selection menu's buttons, and the touch gestures with the pinch
and the momentum scroll. `attachSurfaceEventHandlers` was the last external,
so `document-externals.ts` is gone: every name the document uses now resolves
to a module.
The instrument reads both sides strict. A loose script has to defend Annex B's
block-scoped function declarations, and the printer does that by hoisting a
`var` and renaming the function, so one side carried a rename the other could
not. Neither name escapes its block, so the two readings agree on behaviour
and only the strict one can be compared. It has its own acceptance case.
Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57,
unbound catches 2, number properties 2, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the document's opening declarations into a module
The document shell carried the IIFE opener and the eight declarations inside
it, so it splits the way the message-bridge slice did: the shell keeps the
HTML and the opener, a new slice file holds the declarations, and the byte pin
proves the join is unchanged.
With this every line of the document's script has a module behind it.
Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the whole document script against the modules
Every line of the script now has a module behind it, so the whole thing can be
compared at once. This is the review of the move, as one number per class:
qualifier 609 references + 73 declarations = 682 sites
var rebindings 373, the document's 446 declarators less those 73
curly braces 279, the number measured before any of this started
unbound catches 36 of 38; two name their error and report it
Number properties 17, also measured up front
shorthand properties 4, two SGR flags written twice each
unshadowed names 7
A seventh class was needed and is counted like the others: a binding that
shadowed a document variable stops being a shadow once that variable moves
onto the scope, so the printer stops disambiguating it. It has its own
acceptance case.
The module order lives in one file that both this test and the generator read,
so neither can drift from the other.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): keep only the lint directives that do something
Seventeen of the disables were inert: `typescript/no-non-null-assertion` is
not enabled here, and a directive naming two rules on one line is not parsed
at all, so the one rule that did apply was being ignored too. The changed-code
quality gate reports an inert directive as a finding.
The two that matter are back, one rule per line: the guard-as-expression in
the observer disposal, and the local the document declares and never reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): generate the terminal document from its modules
The WebView document is no longer a hand-written IIFE pasted into a template
string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts`
and the 36 modules under `src/terminal/document/` in document order, strips
their imports, exports and line-scoped lint directives, substitutes the
`document-constants.ts` exports textually, reprints each with esbuild and wraps
the result in one IIFE. `terminal-webview-html.ts` composes the shell, that
generated script and the close fragment. The artifact is gitignored and built by
postinstall, like the two engine artifacts.
The emitted document is token-equivalent to the old one under eight counted
normalisation classes, each pinned as an exact number in
`document/terminal-document-flip.test.ts` against the pre-flip text:
qualifiedReferences 609
scopeFieldDeclarations 73
rebindings 373
bracedBodies 279
unboundCatches 36
numberProperties 17
shorthandProperties 4
unshadowedNames 7
Any other difference fails with the token index and both sides. The second case
pins that the new document adds the scope object and nothing else.
Ruling 17: the behavioural tests now grep the generated document through
`XTERM_HTML`, never a module source, so every assertion still speaks about what
the WebView runs. Every assertion stays and the `expect` count per file is
unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22,
keyboard-avoidance 18, query-reply 14. One control per file was run by deleting
the module line the updated pattern guards; all seven red, and the tree restores
green.
Pattern changes, old -> new.
terminal-webview-scroll-routing.test.ts
var deltaY = ts.lastY - y; -> const deltaY = ts.lastY - y;
smoothScrollOffsetY -= deltaY; -> scope.smoothScrollOffsetY -= deltaY;
var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
-> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);
'touchmove' single-quoted, one line -> "touchmove" double-quoted, printer line break
}, { capture: true, passive: false }); -> { capture: true, passive: false }
function momentumStep() -> let momentumStep = function()
pendingNormalScrollDeltaY += deltaY; -> scope.pendingNormalScrollDeltaY += deltaY;
if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) {
normalScrollFrameId = requestAnimationFrame( -> scope.normalScrollFrameId = requestAnimationFrame(
pendingNormalScrollDeltaY = 0; -> scope.pendingNormalScrollDeltaY = 0;
cancelAnimationFrame(normalScrollFrameId); -> cancelAnimationFrame(scope.normalScrollFrameId);
var writeQueueHead = 0; -> scope.writeQueueHead = 0;
writeQueueHead++; -> scope.writeQueueHead++;
writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);
surface.style.transform = 'translate(' + panX -> scope.surface.style.transform = "translate(" + scope.panX
getVisualPanY() + 'px) scale(' -> getVisualPanY() + "px) scale("
var FRICTION = 0.972; -> const FRICTION = 0.972;
var MIN_VEL = 0.012; -> const MIN_VEL = 0.012;
edgeScrollDir = dir; -> scope.edgeScrollDir = dir;
term.scrollLines(edgeScrollDir); -> scope.term.scrollLines(scope.edgeScrollDir);
// Latching document-level touch dispatcher -> function attachSurfaceEventHandlers(
edgeScrollClientX = clientX; -> scope.edgeScrollClientX = clientX;
edgeScrollClientY = clientY; -> scope.edgeScrollClientY = clientY;
return mode !== 'none'; -> return mode !== "none";
var pixelX = cell.x; -> const pixelX = cell.x;
var pixelY = cell.y; -> const pixelY = cell.y;
...isSafeSgrMouseCoordinate(cell.y)) return -> ...isSafeSgrMouseCoordinate(cell.y)) {
...isSafeSgrMouseCoordinate(sgrRow)) return -> ...isSafeSgrMouseCoordinate(sgrRow)) {
if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress;
if (mouseTrackingMode === 'x10') return sgrPress; -> if (mouseTrackingMode === "x10") { return sgrPress;
if (mouseTrackingMode === 'x10') return press; -> if (mouseTrackingMode === "x10") { return press;
if (col > 126 || row > 126) return ''; -> if (col > 126 || row > 126) { return "";
document.addEventListener('touchend' -> document.addEventListener( "touchend"
}, { capture: true, passive: true }); -> { capture: true, passive: true }
notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...)
document.addEventListener('touchstart' -> document.addEventListener( "touchstart"
var clickInput = buildMouseClickInput -> const clickInput = buildMouseClickInput
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput });
terminal-webview-text-zoom.test.ts
var CLAUDE_STATUS_DOT = -> scope.CLAUDE_STATUS_DOT =
var PRIVATE_MODE_SCAN_TAIL_LIMIT -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT
\n\n function enqueueWrite -> \n function enqueueWrite
var terminalFontFamily = -> scope.terminalFontFamily =
output = terminalFontFamily; -> output = scope.terminalFontFamily;
String.fromCharCode(0x23fa) -> String.fromCharCode(9210)
TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)
EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)
data.replace(CLAUDE_STATUS_DOT_PATTERN, ...) -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR )
writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data))
var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData)
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
statusDotPendingSelector = false -> scope.statusDotPendingSelector = false (x2)
term.open(surface) -> scope.term.open(scope.surface)
term.unicode.activeVersion = '11' -> scope.term.unicode.activeVersion = "11"
enqueueWrite(ESC + '[0m' + replayData) -> enqueueWrite(scope.ESC + "[0m" + replayData)
fontFamily: terminalFontFamily -> fontFamily: scope.terminalFontFamily
fontWeight: '300' -> fontWeight: "300"
fontWeightBold: '500' -> fontWeightBold: "500"
terminal-webview-engine.test.ts
var webglAddon = null; .. var webglRecoveryTimer = null;
-> the refreshTerminalSurface()..init( block, with the scope preamble
window.addEventListener('resize' -> window.addEventListener("resize"
'terminal init failed' -> "terminal init failed"
'terminal message failed' -> "terminal message failed"
var everReady = false; -> scope.everReady = false;
everReady = true; -> scope.everReady = true;
fatal === undefined ? !everReady : !!fatal -> fatal === void 0 ? !scope.everReady : !!fatal
msg.type === 'init' && !everReady -> msg.type === "init" && !scope.everReady
/fatal === undefined \? !ready\b/ -> /fatal === void 0 \? !scope\.ready\b/
if (msg.type === 'ping') -> if (msg.type === "ping")
notify({ type: 'pong', pingId: msg.id }) -> notify({ type: "pong", pingId: msg.id })
terminal-webview-reflow.test.ts
} else if (msg.type === 'reflow') { -> } else if (msg.type === "reflow") { (x2)
var MIN_FIT_COLS = 20; -> scope.MIN_FIT_COLS = 20;
if (cols < MIN_FIT_COLS) return; -> if (cols < scope.MIN_FIT_COLS) {
flog('measure-skip-small-width' -> flog("measure-skip-small-width"
notify({ type: 'measure-result', ... }) -> notify({ type: "measure-result", ... })
var dispatch = { mode: 'idle' -> const dispatch = { mode: "idle"
window.addEventListener('message' -> window.addEventListener("message"
terminal-keyboard-avoidance-webview.test.ts
\n // reflow() -> \n function reflow(
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
\n var panX -> \n scope.panX
TERMINAL_REFLOW_JS fragment import -> the reflow(cols, rows)..notify( slice of the document
terminal-webview-query-reply.test.ts
attachTerminalQueryReplyBridge(term, gen) -> attachTerminalQueryReplyBridge(scope.term, gen) (x2)
term.attachCustomKeyEventHandler(function() { return false; })
-> term.attachCustomKeyEventHandler(function() { \n return false; \n });
term.textarea.readOnly = true -> term.textarea.readOnly = true;
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
terminal-webview-url-tap.test.ts
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the
generated document's digest, 730472 -> 723480 bytes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): delete the slice constants and injected fragments
The document is generated from its modules now, so the strings it used to be
pasted together from are dead. Deleted: the fourteen slice constants under
`terminal-webview-html/` (host-message-router, message-bridge,
mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants,
runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-touch-gestures,
term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write,
write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`,
`document-close.ts` and `theme.ts` stay: the shell and close are still the
document's HTML, and `theme.ts` is where `document-constants.ts` reads the
palette from.
Ruling 17, second commit. Tests that asserted the extraction mechanism itself
went with it: they compared one module's emission against the slice text it was
extracted from, and the flip test now pins the whole document against the whole
pre-flip script with the same eight classes. Deleted, all under `document/`:
fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge,
mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing,
mouse-report-cell, path-tap, query-reply, reflow, runtime-constants,
runtime-state, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures,
tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery,
wheel-scroll. `document/url-tap.test.ts` stays: it pins against
`URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and
still has a consumer.
Tests that asserted behaviour through a deleted string now read the generated
document. `document/generated-document-region.test-support.ts` is the one way in:
`documentScopePreamble()` returns the scope object the document opens with, and
`generatedDocumentModule(name)` re-emits a module and refuses unless the document
carries that text verbatim, so an evaluated block is the WebView's own bytes. The
two local copies of the preamble in the engine and text-zoom tests were folded
into it.
Moved, with every assertion kept and the `expect` count per file unchanged:
terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts 34
terminal-webview-theme-injected.test.ts -> terminal-webview-theme.test.ts 14
terminal-webview-query-reply.test.ts 14
terminal-path-tap.test.ts 25
terminal-webview-url-tap.test.ts 33
terminal-keyboard-avoidance-webview.test.ts 18
terminal-webview-reflow.test.ts 22
terminal-webview-text-zoom.test.ts 59
terminal-webview-engine.test.ts 49
Pattern changes, old -> new.
terminal-webview-reflow.test.ts
if (!term || isAlternateBufferActive()) return;
-> if (!scope.term || isAlternateBufferActive()) {
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
var wasAtBottom = buffer.viewportY >= buffer.baseY;
-> const wasAtBottom = buffer.viewportY >= buffer.baseY;
term.scrollToBottom(); -> scope.term.scrollToBottom();
if (nextCols === term.cols && nextRows === term.rows) return;
-> if (nextCols === scope.term.cols && nextRows === scope.term.rows) {
The other eight files kept their patterns; only the text they read changed, from
a deleted constant to the document block. The harnesses that evaluate a block now
build the document's scope object instead of declaring the vars it replaced, and
hand the terminal in as `scope.term`.
Controls, one per file: the module line an updated pattern guards was removed,
the document rebuilt, and the test run. All red, and the tree restores green.
query-reply terminalDataRepliesEnabled = true -> query-reply test, 2 failed
path-tap const parsed = parsePathLineCol(...) -> path-tap test, red
keyboard-avoidance-metrics contentBottomRow -> keyboard-avoidance test, 4 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
webgl-recovery new window.WebglAddon.WebglAddon() -> engine and text-zoom tests, 4 failed
osc-link-tap return parsePathLineCol(value) -> url-tap test, 1 failed
terminal-theme scope.term.options.minimumContrastRatio = ...
-> theme test, 4 failed
write-queue scope.writeQueue[scope.writeQueueHead] = undefined
-> write-queue test, 4 failed
`document-scope.ts` docstrings named the slice each field belonged to; they name
the owning module now. Three module comments pointed at deleted injected files
and point at the modules instead. Neither changes the document: esbuild drops
comments, and the byte pin is unmoved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the right number of counted classes
The flip test's title still said seven; the table it asserts has eight.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name the shape applyTerminalTheme writes through
The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test.
`applyTerminalTheme` touches exactly two slots on the terminal it is handed, so
`terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and
the test's parameter and both fixtures use it. The theme is optional on the way
in because `applyTerminalTheme` is what writes it.
No cast. The type is erased by the generator's transform, so the document is
unchanged and the flip test's class table and the byte pin both still hold.
Control: restoring the `object` parameter reproduces the finding at
terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type
it exits 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the dead URL-tap constant and two stale reflow guards
Round 1 fixes, all three folded here.
1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with
`document/url-tap.test.ts` deleted alongside it. The document is generated
from its modules now, so that constant was a second copy of the URL-tap group
with no consumer but its own tests. terminal-webview-url-tap.test.ts's
resolver harness reads the document's own text instead, the path-tap,
url-tap, osc-link-tap and surface-tap modules in document order through
`generatedDocumentModule`, which refuses unless the document carries each
verbatim. Its 33 expects all stay. One mechanism-only assertion went with the
file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts`
pin of the three emissions against the constant, which the flip test's
whole-document pin already covers. The file's other exports stay.
The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts
concatenated terminal-webview-url-tap.ts into its `source`, and its
`notify({ type: 'terminal-tap' });` assertion was matching the constant's
single-quoted text, not the document. The read is dropped, since nothing else
in that file needed it, and the assertion is the document's form:
notify({ type: 'terminal-tap' }); -> notify({ type: "terminal-tap" });
Its 95 expects stay. Leaving the read in place would let a document assertion
pass against a module source, which is the hazard this lane exists to remove.
2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer
exists, so it could not fail:
expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}')
-> expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1)
Same intent against the generated document: the reflow module's emitted text
is in the document exactly once. The case is renamed to say so and the
comment above it describes the generator, not the deleted template.
3. Same file, the routine assertion still passed as a substring of the qualified
call; qualified as line 30 already was:
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
Its 22 expects stay.
Controls, each verified to have changed the file first, all red, tree green
after restore:
osc-link-tap return parsePathLineCol(value) -> url-tap test, 3 failed
surface-tap notify({ type: 'terminal-tap' }) -> scroll-routing, 1 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
module order 'reflow' listed twice -> reflow test, expected 2 to be 1
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): retire the last module concatenator and guard the order list
Round 2 fixes, all five folded here.
1. Deleted terminal-webview-html-source.test-support.ts.
`readTerminalWebViewHtmlSource()` had no consumers left once the behavioural
tests moved to the generated document, and it was the last thing that built a
document-shaped string by concatenating module sources — its filter admitted
`.test-support.ts` files too, so it could have grown one. Confirmed by grep
that the only occurrence of either name in the repository was its own
declaration.
2. New document-module-order.test.ts asserts both directions: the non-test,
non-test-support `.ts` files under `document/` are exactly
`{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`,
and no name is listed twice. `document-constants` is the one exception
because it is never emitted: its exports are substituted into the modules
that import them as literals, so the document carries its values without
carrying the module. A module added here and forgotten there would be dead
code that reads as live; a name left after its file goes makes the generator
throw at build time rather than at review time.
3. terminal-document-flip.test.ts's docstring now carries the retirement policy
from ruling 18: the test is the proof of the flip and holds only while no
module changes, the first lane that must change one retires it together with
`terminal-document-pre-flip-script.txt`, and the standing pin from then on is
`terminal-document-identity.test.ts`, whose fixture regeneration is a review
event. Comment only.
4. terminal-document-equivalence.test-support.ts said 57 reassigned variables
and "Four classes and no others". It now says 73 declaration sites and eight
classes, with each class's measured figure named. Two doc comments sat above
the wrong declaration and were moved onto what they describe: the
`NUMBER_GLOBALS` one down to that constant, and the printing one down to
`significantTokens`, with `STRICT_DIRECTIVE` given its own line.
5. build-terminal-document-script.mjs substituted constants with
`replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a
constant's value are read as replacement patterns. The substitution is now
`substituteDocumentConstants`, exported so it can be tested directly, and
replaces with a function.
Controls, each verified to have changed its input first, all red, tree green
after restore:
plant document/zz-planted-module.ts -> order guard, "+ zz-planted-module"
drop 'wheel-scroll' from the order -> order guard, "+ wheel-scroll"
revert to the string replacer -> 4 failed, "a $& b" became "a marker b"
The `$n` case is deliberately absent from that table: the pattern has no capture
group, so `$1` is already literal under either form and a case for it could not
tell them apart.
The document did not move. The byte golden, the digest and the flip test's class
table are all unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make the flip comparator refuse what it was accepting
Round 2 items 6 and 7, both in the equivalence instrument.
6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving
the two were the same binding, so an unrelated rename ending in a digit would
have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`,
an explicit list of pre-flip name, generated name and declaring module. The
whole script has one entry: `term2` -> `term` in `query-reply`, which is the
`term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven
sites in all. That is stated in the docstring rather than encoded as a second
pin, since the flip test already pins the total.
7. Brace absorption treated every unexpected `{` as a linter-added body and
absorbed any later `}` while one was outstanding, so a bare block anywhere
would have been swallowed. `isBraceableHeadBody` now requires the open to be
the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its
`(` and reading the keyword before it — and `matchingCloseIndex` records the
index the close must appear at, so the absorbed `}` is that body's own.
That check had to move ahead of the equality check. Wherever a braced body
ends a block, the baseline's next token is a `}` as well, so pairing them
would consume the wrong one and leave the counts right for the wrong reason.
Both refusals are tested over snippets:
function f() { return value2; } vs return value;
-> token 6: expected name value2, generated name value
let value = 1; use(value); vs { let value = 1; } use(value);
-> token 0: expected name let, generated {
and the braceable heads are tested one by one, `if`, `for`, `while`,
`if`/`else` and `do`, so the new rule is shown to accept every shape the `curly`
rule produces and not only the one the document happens to exercise.
Controls: restoring the shape rule fails the first refusal case and nothing
else; restoring the accept-any-brace rule fails the second and nothing else.
The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7.
Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The
tightened rules put the file over the 300-line cap, and a `max-lines` disable is
forbidden, so the token reader moved to its own module: that side answers what a
script says, and says nothing about which differences between two of them are
allowed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
190 lines
7.0 KiB
JavaScript
190 lines
7.0 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_MODULE_ORDER,
|
|
TERMINAL_DOCUMENT_SCOPE_MODULE
|
|
} 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')
|
|
|
|
export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join(
|
|
import.meta.dirname,
|
|
'..',
|
|
'src',
|
|
'terminal',
|
|
'terminal-webview-document-script.generated.ts'
|
|
)
|
|
|
|
/**
|
|
* The document's whole script: every module in the order the document had, inside the one function
|
|
* scope it has always been.
|
|
*/
|
|
export async function buildTerminalDocumentScript() {
|
|
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.
|
|
for (const name of [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER]) {
|
|
emitted.push(await emitTerminalDocumentModule(path.join(documentDirectory, `${name}.ts`)))
|
|
}
|
|
return `(function() {\n${emitted.join('\n')}\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`
|
|
)
|
|
}
|
|
|
|
if (process.argv[1] === import.meta.filename) {
|
|
await main()
|
|
}
|