refactor(timew): split panel.luau into modules, teach the suite to load them #14
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "upgrade/u4-modules"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
panel.luauwas 1701 lines holding six layers. It is 1025 now and holds only what anentry has to: state, the handlers that move it, the region builders, and the assembly of
the tree.
The graph
Acyclicity, how it was checked. The seven files'
require("./x.luau")strings wereextracted and walked with a colouring DFS. Result: acyclic, topological order
fmt < parse < query < timew < viewmodel < panel < bar; every require target has exactlyone alias file and no alias file lacks a requirer. No back-edge was wanted and none was
added —
statetravels down as an argument, never up as a require.Note that the runtime gate for this is the shell's, not the suite's:
pushRequiredModulewalks
m_moduleStackand raisescircular require: <full chain>(
luau_host.cpp:2976-2985). The bareluauCLI does not detect cycles — a two-filecycle built in
/tmpran clean and exited 0 under it — so a cycle would passluau test.luauand fail to load the plugin. That asymmetry is why the split keeps thegraph shallow rather than routing edges through an aggregator.
Modules
fmt.luauclock,span,dayLabelparse.luauUNTAGGED,trim,toast,unquote,duration,clock,intervalquery.luaumatching,barRoles,tagColor,barPeaktimew.luauDAYS/DAY_WALK_MAX/ALL_WALK_MAX, the parsers,cmd.*, therunAsyncwrappers,execnoctalia, inside function bodies onlyviewmodel.luaubuild(state),TAG_COLORSos.timepanel.luaustate,apply/commit, fetches, handlers, builders,render,onOpen/update/onIpcbar.luauConfig, tick,sync,render,onClickThree deviations from the shape in
UPGRADE.md:No
config.luau. The manifest declares settings on the bar entry only; the panel readsnone.
Confighas exactly one caller, duplicates nothing, and extracting it would add afile and a second stubbed environment to the suite for no present reader. U6 gives the
panel formatting settings — that is its first real second consumer, and the right task to
move it. A module with one caller is a file to open, not a boundary.
viewmodel.luaurequirestimew.luau. It reads exactly two pure values from it:tagMatches(the same substring test the walk filters with) andDAYS(the chart is aswide as the fetch). It calls nothing impure. Moving
tagMatchesintoquery.luauwouldcut the edge, but the tracker's own layering puts the view model above the commands, and
the move would churn the suite's
T.tagMatchesasserts and the pure-core counter's notefor an edge that is a read of two constants.
The three fetch bounds are exported.
DAYS,DAY_WALK_MAXandALL_WALK_MAXwerelocals of the one file that both built the commands and drew the chart. They belong with
the commands, and the panel needs
DAYSforRIGHT_Wand the trend caption. So they livein
timew.luauand are read off it. The suite's frozen scripts stay frozen literals, and anew assert ties the exported numbers to them — a pair that could not be checked against
each other at all before.
bar.luau: what is shared and what is not
The bar is its own entry with its own Luau VM and its own module cache, so a shared module
means each VM loads its own instance of one file — the sharing is of source, not of
state, and neither shared module has any state to share.
formatElapsedFmt.clocktrimParse.trimtoastneeds it anywayunquoteParse.unquotetoastParse.toast"timew is not on PATH"and"timew failed (exit N)""(untagged)"Parse.UNTAGGEDtimew.tagsand the panel compares with~=. It was a literal in each entryelapsedConfigsyncshell program./timew.luaufor one string would put a bar tick downstream of the whole command layer. It was an unguarded duplicate; it is now held againstTimew.cmd.activeInterval(), the way thetimew startargv already wastimew start/timew stopargvConfigand its stop is a branch of one click handler; already pinned against the panel'sThe test loader, and why it is not the one that was specified
The brief called for a loader that resolves
./x.luauitself, caches per path, injectsstubbed globals and injects itself as
require. Half of that is impossible on thisruntime, and it was checked rather than assumed:
io.type(io)isnilinluau. There is no way to read a module's source, sothere is nothing to hand
loadstring._Gis frozen.table.isfrozen(_G)is true and writes raise. A CLI-loaded module'sglobals table has
__index = _G, so norequireshim can be placed in the chain beforethe module body runs.
with
could not resolve child component.name and demands it end in
.luau(luau_host.cpp:2948-2951); the CLI takes it as a stemand appends
.luau. For a fileFthe shell needs the request to beFand the CLI needsit to be
Fminus.luau. Different strings, always.So any file containing
require("./x.luau")is unloadable byluau test.luau— entriesincluded — and that would have taken roughly 2000 of the suite's 2443 lines with it.
What ships instead: one
<name>.luau.luaubeside each module, six lines, forwarding tothe real file. The CLI finds it for
./x.luau; the shell resolves the same string tox.luauand never opens the alias. They are not in the watch set either — that is the entry plus
LuauHost::loadedModulePaths(), which is populated only for modules actually required(
luau_host.cpp:3046). They ship as inert bytes in the plugin directory. That is the cost,and it is the thing to challenge hardest in review.
The half that is achievable from inside the suite is the environment, done after load,
because a module's globals table is a plain writable table with a replaceable metatable:
chainTo(entryEnv, modules)points each module's globals at the entry that required it,which is the chain the shell builds (
luau_host.cpp:3013-3022).timew.luauthereforesees the panel's stubbed
noctalia, andviewmodel.luausees its frozenos.fmt,parseandqueryare required by both entries, and this suite gets one instancewhere the shell builds two. That is sound only while they read nothing but the standard
library — so rather than assume it, they run under an environment that raises on any
global the CLI does not itself define.
Nested require, proved two levels deep.
panel.luaurequires./parse.luau, whichrequires
./fmt.luaurelative to its own directory. SwapFmt.spanfor a sentinel and ithas to come back out of
Parse.interval, which lives one file further down — one assertcovering both halves at once, that the second hop resolved and that it resolved to the same
cached table rather than a second copy.
A module without its alias is not an assertion failure, it is a load failure. Moving
query.luau.luauaside kills the gate attest.luau:31witherror requiring module "./query.luau": could not resolve child component "query.luau"before a single check runs. There is no state in which a module lacks its alias and the
suite still prints
ok.Gates
Lint follows the new requires: a literal
noctalia.getConfig("not_declared_anywhere")planted in
query.luau— two hops below the entry — is reported aserror query.luau:67 reads undeclared setting 'not_declared_anywhere'.Check count
viewModel: 12 states, 1739 field comparisonsbefore and after. Unchanged: the view modelcomputes the same fields from the same state, and the twelve states are driven through the
same handlers.
Four checks were added, and each was shown to fail on a planted defect:
parse.luaugrows its ownspaninstead of requiring fmttest.luau:78: parse.luau's require of ./fmt.luau did not land on the fmt module the entry holdsquery.luaureadsnoctaliatest.luau:62: a module below the host line read the global 'noctalia'viewmodel.luaudrops alocaltest.luau:2595: viewmodel.luau declared a global: chartPeaksynccommand2>/dev/nullfrom bar's copytest.luau:688: bar's sync command has drifted from Timew.cmd.activeInterval()One existing check was repaired: the pure-core counter wrapped every entry of
Fmt,Parseand
Queryin a counting closure, andParse.UNTAGGEDis a string. Wrapping it left themodel comparing every row's tag against a function for the length of the measurement — the
untagged branch switched off underneath the very count that is supposed to be of an
untouched repaint. It now skips non-functions.
Line counts
Before (
main):After:
Net +287 (+257 excluding the 30 lines of alias files).
panel.luau−676,bar.luau−7,test.luau+157. A split that only moved code would be net zero; the growth is moduleheaders, the loader note, and the four new checks. No behaviour changed.
Watch set, and what a leaf edit does
The shell watches the entry plus every module that entry successfully required
(
PluginScriptWatcher::rebuild, fed fromLuauHost::loadedModulePaths()):yuris/timew:panel—panel.luau,fmt.luau,parse.luau,query.luau,timew.luau,viewmodel.luauyuris/timew:bar—bar.luau,fmt.luau,parse.luau*.luau.luaualiases,plugin.toml,translations/en.jsonEditing a leaf reloads every entry that reached it, not just the file. Saving
fmt.luaufires the watch on both entries, each tears its VM down and rebuilds it, and each rebuilds
its own module cache from scratch — so the panel's
statetable is recreated at itsdeclared defaults and the bar's
active/startEpochgo back to idle until its nextsync. Savingviewmodel.luaureloads the panel only, since the bar never required it. Amodule that fails to load does not enter the watch set (recorded only after a successful
load,
luau_host.cpp:3044-3047), so a leaf edited into a syntax error stops being watchedand the next fixing save has to come through the entry's own watch.
What a reviewer should challenge hardest
exist only so a test runner can load production code. The alternative was losing most of
the suite. Judge whether that trade is right — the aggregator variant was measured and is
worse (still five shims, plus a real file, and its collapsing form is a cycle the CLI
silently accepts and the shell rejects).
fmt,parseandqueryare sharedbetween two entries in the suite and would not be in production. The purity environment is
what makes that sound; if any of those three ever needs a host global, that assumption
breaks and the suite must grow a second instance it currently cannot build.
viewmodel.luaurequiring the command layer for two pure values. It is the one edge inthe graph that is a convenience rather than a necessity.
passesFilterwas a module local overmodule state;
stateis an argument now, so it is one closure per repaint. One allocationa second, not one per row — but it is a real change from what the old comment claimed.
Timew.DAYSis a display constant living in the command layer. It is one number doingtwo jobs, and it had to land on one side.
Verdict: APPROVE
Reviewed by the integrator after two delegated reviewers hung without producing output. Every
number below is derived here, not taken from the PR body.
Verified
Graph, independently extracted from the
requirestrings rather than read from the PR:fmt -> (none),query -> (none),parse -> fmt,timew -> fmt parse,viewmodel -> fmt parse query timew,panel -> all five,bar -> fmt parse. Acyclic;topological order as claimed. Two leaves, one entry per widget.
The split is a pure move, with two declared exceptions. Multiset diff of non-comment,
non-blank code lines between
11d8841:timew/panel.luauand the concatenation of the new panelplus all five modules: 16 lines only in baseline, 44 only in new. All but two classes are
UNTAGGED -> Parse.UNTAGGEDqualification and module scaffolding (local M = {},return M,the export lines). The two real restructures:
viewModel()reading the module-levelstatebecameViewmodel.build(state)taking it asa parameter. Forced by the boundary - a module cannot see the panel's local.
passesFilterto one constructedonly when
state.mode ~= "all". The body is byte-identical(
return Timew.tagMatches(row.tag, state.filter)) and the value handed toQuery.matchingis the same in both modes, so this is equivalent; it also stops allocating a closure that
all-time scope discards.
Neither is a behaviour change, but both are rewrites rather than moves and the PR body should
say so - on this project "the move is provably a move" is the standard.
Settings-drift protection survived the split. Planted
getConfig("not_declared_anywhere")in
query.luau, two hops below the entry:error query.luau:21 reads undeclared setting 'not_declared_anywhere'-> 1 error. Restored,back to 0/0. Lint follows requires.
Gates:
12 states, 1739 field comparisons / ok, and0 errors, 0 warnings. Check countunchanged from
main, as claimed.Mutations
Five against cross-module boundaries, each restored byte-identically and each verified to have
actually applied (my first pass produced two false survivors from patterns that missed - worth
recording, since an unapplied mutation reports as a survivor and looks like a finding):
Parse.UNTAGGEDsentinel changedQuery.matchingdrops its predicate argumentFmt.clockrenamed, breaking a cross-module callTimew.DAYShalvedThe alias scheme
Accepted. The constraint is real and I verified its shape independently at
luau_host.cpp:2947-2951: the shell requires the request to end.luauand resolves it aswritten, while the bare CLI appends
.luau- two different strings for one file, so no singlerequire path loads under both. With no
ioand a frozen_Gthere is no in-suite loader, andthe aggregator variant I proposed was measured and rejected on better evidence than I had: it
raises the file count and, in the form that would cut it, creates a cycle the CLI runs clean
and the shell rejects. A shape that passes the gate and fails to load is worse than five inert
files.
A user installing this plugin is not harmed: the alias files are never required, so the shell
neither opens nor watches them. The cost is five files of noise in the plugin directory and a
drift surface if a module is renamed without its alias - and that case fails the suite at load
rather than silently, which is the right failure.
Advisory
bar.luauandpanel.luaueach keep their owntoast; the PR sharesfmt/parsebut thisuser-visible string still exists twice. It is the exact drift hazard sharing was meant to
close - worth a line in the body saying why it stayed, or closing it in U7.