refactor(panel): add Timew command layer #3
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "refactor/t3a-timew"
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?
refactor(panel): add a Timew command layer
Additive. Every timewarrior invocation the panel makes, and the parsing of every
line it prints, now has one home: a
Timewtable inserted immediately beforepokeBar(). Its callbacks hand back parsed Luau values; no caller has to seestdout. Nothing calls it yet — the eight
runAsyncsites are untouched — so thefetch functions keep their own copies of the same commands. That duplication is
deliberate and temporary, and the follow-up (T3b) deletes it by rewiring the
callers onto this table.
The point of doing it this way round: the commands are hard-won (the awk column
width trick that keeps
deep workfrom becomingdeep, the last-non-blank-fieldtotal, the DOM walk's break condition) so they were copied verbatim rather than
rewritten, and the copy is proved byte-identical before anything moves.
Three layers, in dependency order.
Timew.cmd.*builds command strings and does nothing else. Pure, so theawk programs can be asserted with no shell and no timew. Every command string
in the table comes from here.
Timew.parse*turns stdout into Luau values. Pure.Timew.<name>(…, cb)is the only impure layer: arunAsync, a timeout,and a parse.
Coverage: all eight command sites
Line numbers are on
38e8916(this branch, rebased ontoe3d69aa) and one3d69aa(mainat the time of writing). Both moved when PR #2 inserted thepure core, so neither matches the numbers this description carried at review.
TimewfunctionmainTimew.activeInterval(cb)cmd.activeInterval()fetchActiveTimew.todayTotal(cb)cmd.todayTotal()fetchTotalTimew.tags(cb)cmd.tags()fetchTagsTimew.dayTotals(days, tag, cb)cmd.dayTotals(days, tag)fetchDaysTimew.intervalsForDay(date, walkMax, cb)cmd.intervalsForDay(date, walkMax)loadDayTimew.allIntervals(walkMax, tag, cb)cmd.allIntervals(walkMax)fetchAll, the walkTimew.rangeTotal(tag, cb)cmd.rangeTotal(tag)fetchAll, the all-time totalTimew.exec(cmd, cb)cmd.exec(cmd)runTimew.tagArg(tag)(467) is the ninth pure function and the one that mattersmost: it is the only place a user string reaches the shell, factored out of the
two copies of the same conditional that
dayTotalsandrangeTotaleachcarried.
Parsers are separate from the commands that feed them, so each one can be checked
against a canned string with no timew and no shell:
Timew.parseActive,parseTotal,parseTags,parseDayTotals,parseIntervals,parseRangeTotal,tagMatches. They call the pure core PR #2 added —Parse.trim,Parse.unquote,Parse.clock,Parse.interval,Fmt.span— rather thancarrying a third copy of it.
shQuoteis unchanged and still mandatory:runAsynctakes a/bin/sh -cstring at this plugin API, never an argv array, so every interpolated tag or date
goes through it. Noted in a comment above the table, since the next reader's
first instinct will be to reach for an argv array that does not exist.
Verified
luau --checkis not available in the nixpkgs build (Unrecognized option '--check'), so parse coverage comes fromtest.luau, which requires bothentries: a syntax error in
bar.luauorpanel.luaufails it.test.luaugained 43 asserts across 129 added lines. Twenty cover theparsers on canned stdout — quoted multi-word tags, untagged intervals, the idle
case, empty day totals, blank lines in the tag list, unparseable lines dropped,
the tag filter, and timew's
No filtered dataprose as zero. The rest are newin the fixup: the eight frozen command literals, the bound defaults, and the
tagArgquoting asserts.Parity, and this time it is reproducible
Committed asserts freeze all eight command strings, so the commands no longer
depend on a harness nobody can re-run. The harness still exists to prove those
literals are
main's, and it is quoted below rather than described.Both sides get the
returnblock cut off and a tail concatenated on, so thechunk's locals are in scope, with
runAsyncstubbed to record the command andthe timeout. Twenty-one invocations: the eight sites, the bound defaults, the
tag-carrying ones with and without a filter, and each of those again with a
hostile tag.
Identical byte for byte, including the awk programs,
seq 0 13, thecase/break,2>/dev/nullplacement, and all eight timeouts. The hostile tagevery quoted site was re-run with, and which produced no canary:
It comes back out of
tagArgas one single-quoted argument, apostrophe closedand reopened the POSIX way, and
ls PWNED PWNED2 PWNED3finds nothing.A second harness feeds one canned stdout to each old callback and to the matching
parser and compares the module state the callback wrote against the parser's
return (
active/startEpoch/tags,total,recent,days,dayRows,allRows,allTotal), plusexecagainstrunon exit codes 0, 1 and 127:The one difference is
tags, and it is deliberate — see below.Decisions
Timew.exec(cmd, cb)takes a command string, notTimew.start(tag)/Timew.stop(), and T3b should not add the pair either.bar.luau:215buildsthe identical two strings with its own
shQuote(99) and cannot reachpanel.Timew, because the plugin API forbidsrequirebetween entries. Atyped pair would put the tag's quoting inside the table for the panel and leave
it at the call site for the bar: two idioms for one operation. Changing that is
a decision about the no-require rule, not about this table. Written into the
comment above
Timew.execso it is not re-litigated.days or DAYS,walkMax or DAY_WALK_MAX,walkMax or ALL_WALK_MAX. Thesefunctions call
runAsync, so they are impure by construction and the purityargument never reached them, and the bounds were the file's only unquoted
interpolations.
tagstays a parameter:filtergenuinely varies at runtime.Timew.tagsreturns every tag;RECENT_MAXcapping stays with the caller.How many fit on screen is not a parsing question. This is the one behavioural
difference from
fetchTags, which capped inside its loop, and it is nowasserted rather than merely claimed:
parseTagson six tags must return six.A T3b rewire that forgets the cap fails the suite instead of overflowing a chip
row in a panel that cannot clip.
matchesFilterloses;Timew.tagMatchessurvives.matchesFilter(318)reads the module's
filterdirectly, which a parser taking its input as anargument cannot do. Its three call sites (813, 1110, 1146) become
Timew.tagMatches(row.tag, filter)in T3b, andmatchesFilteris deletedthere — not here, because wave 1 only adds, and two of the three sites are not
otherwise touched by the rewire. Recorded in the comment on
tagMatches.allIntervalsfilters the tag in Luau, not in timew. That is the existingbehaviour and the DOM walk takes no filter, but it means
walkMaxappliesbefore the filter: a tag whose occurrences are older than 200 intervals is
invisible in the list while
rangeTotalstill counts it. Preserved as-is,documented at the function, and worth its own issue. If it is ever fixed the
fix is in timew, not in Luau.
activeIntervalmirrors the epoch-conversion command actually in the file(
timew get dom.active.start, thendate -d), not adom.active.start dom.active.duration dom.active.tagstriple.date(1)doesthe conversion so no Luau code parses a timestamp.
Timew.parseActive) rather than nestedunder
Timew.parse.*. One table, one lookup, no ceremony.Timew.cmdisnested because it is a different kind of thing: strings, not behaviour.
Why the command strings are frozen literals
Frozen literals are change-detector tests, which are usually not worth having.
These earn it. The awk programs are hand-tuned, no argument varies them, and
after T3b deletes
fetchActive…runthere is nothing left in the repo to diffthem against. A typo in any of them passes every other check.
They are reachable as values only because
Timew.cmdis pure. A harness cannotbe committed instead: the luau CLI gives each required module its own global
table, so a
runAsyncstubbed fromtest.luauis invisible insidepanel.luau. That is why the strings had to become values rather than theconsumer becoming stubbable.
The
tagArgasserts are not change detectors. They are the only automated checkthat the injection path stays closed, and they cover an apostrophe, a newline, a
command substitution and a
'; rm -rf /; #break-out.Each new assert was checked for non-vacuity by mutation:
NR==3→NR==4,$NF→$1,DAY_WALK_MAX→61, dropping the empty-string arm oftagArg, andre-adding the
RECENT_MAXcap toparseTagseach fail the suite, at theexpected assert.
Verdict: REQUEST_CHANGES. (Posted as a comment review: Forgejo refuses a
REQUEST_CHANGESstate on one's own pull request, so the verdict is stated hererather than carried as review state.)
The copy is good. I rebuilt the parity harness you described and threw away, and
it agrees with you: all eight command strings and their timeouts are byte-identical
to the ones on
main, and so is every parser output except one.shQuoteholdsagainst everything I could throw at it, including a newline-embedded
rm -rfandthree
$(touch)canaries, none of which fired. The diff is purely additive (zerodeleted lines), touches only
timew/panel.luauandtest.luau, and no excludedfile is committed. Nothing here is a runtime defect, and I could not make one
happen. I am requesting changes on three narrow points, all of them cheaper now
than after T3b: the command strings this PR exists to preserve have no check in
the repo and cannot get one without a small change to the table's shape, the one
behaviour delta you did introduce has no test guarding it, and three parameters
exist to serve callers that never will.
Findings
1. The parity evidence cannot be committed as written, and after T3b nothing can re-derive it —
timew/panel.luau:279,:351I rebuilt your harness and it agrees with you, so this is not doubt about the
result. It is that the result lives only in the PR description. Once T3b deletes
fetchActive…run, there is nothing left to diff the copy against, and thestrings that make this PR worth doing lose their last check.
They have none today. Your 20 asserts cover the parsers; every trick the
description brags about lives in the command strings: the
NR==3{w=length($1)}column cut that keepsdeep workwhole, theNF{l=$NF}last-non-blank-field, the
case/breakearly exit, the 14-iteration loop, the2>/dev/nullplacement, and theshQuotecomposition. A typo in any of thempasses
luau test.luauand passesnoctalia plugins lint timewtoday.Before you reach for the obvious fix: you cannot move the harness into
test.luauby stubbingrunAsync, because the luau CLI gives every requiredmodule its own global table. A stub assigned in the test is invisible inside
panel.luau:That is precisely why your harness had to
sedthereturnblock off andconcatenate: one chunk, one global table. It works, and it is also why it can
never be a committed test.
The way out is to stop stubbing the consumer and make the strings pure values.
Two changes, both of which shorten the file:
tagArgalso deletes the duplicated conditional now sitting at 299 and 352.I prototyped this on a copy —
tagArgpluscmd.todayTotalandcmd.rangeTotalwired through — and re-ran both harnesses against it:
The asserts that probe runs, which is what belongs in
test.luau:Extend
Timew.cmdto all eight and freeze each literal. Frozen literals arechange-detector tests, which I would normally argue against, but a hand-tuned awk
program that no argument can vary is one of the few places they earn it — and the
tagArgasserts are not change-detectors at all, they are the only automatedcheck that the injection path stays closed.
If you would rather keep this PR a pure move, say so and I will sign it off with
Timew.cmdas T3b's first commit instead. What I do not want is for it to landafter the old strings are gone.
2.
parseTagsdrops theRECENT_MAXcap, and nothing tests it —timew/panel.luau:222vs:462This is the only place where old and new disagree. Feeding both the same six-tag
stdout:
fetchTagscaps inside the loop (#recent < RECENT_MAX, line 462);parseTagsreturns everything. Your description calls this deliberate and I agree with the
call — how many fit on screen is not a parsing question. The problem is that it is
deliberate only in prose. When T3b rewires
fetchTagsontoTimew.tagsandforgets the cap,
recentgets every tag the user has ever used, the chip rowoverflows a panel that cannot clip, and the whole suite stays green.
The assert that stops it, next to the existing
parseTagsone:Cheap, and it turns a paragraph in a PR description into something T3b trips over.
3.
daysandwalkMaxare parameters with exactly one possible argument —timew/panel.luau:298,:316,:337Answering your 5c below, but it is also the one bit of over-engineering here.
dayTotals(days, …)will only ever be handedDAYS,intervalsForDay(…, walkMax, …)only
DAY_WALK_MAX,allIntervals(walkMax, …)onlyALL_WALK_MAX. All threeconstants are module-level in this same file, forty lines up.
The purity argument does not reach them: these three functions call
noctalia.runAsync, so they are impure by construction, and the genuinely purepart (
parse*) already takes no bounds and needs none. What the parameter does buyis the file's only unquoted interpolation sites —
.. walkMax ..and.. (days - 1) ..go into the command string raw. Harmless today because both arenumeric constants, and identical to
main, so this is not a regression. But it isa quoting exception that exists to serve a caller that will never exist.
Default them and the call sites get shorter without losing anything:
Keep the
tagparameters exactly as they are.filtergenuinely changes atruntime, and that is a real argument.
4. Every line number in the coverage table points at the wrong code
The "line in this PR" column is the old line plus 203 in every row, so it lands on
the function being replaced, not the new one. Line 428 is
fetchActive, notTimew.activeInterval. T3b will navigate by this table.Actual lines:
activeInterval269,todayTotal279,tags288,dayTotals298,intervalsForDay316,allIntervals337,rangeTotal351,exec363. Theline on maincolumn is correct throughout. While you are in there: thedescription says 30 asserts, the file has 20 across 35 added lines.
5. Give
matchesFiltera disposition before it becomes permanent —timew/panel.luau:148and:192tagMatchesearns its place: it is the parameterised form, and a pure parsercannot read module state. But
matchesFilterhas three call sites (556, 853, 889),and only 556 dies when T3b rewires
fetchAll. Left alone, the file keeps twospellings of one predicate forever.
Say in the T3b description that 853 and 889 become
Timew.tagMatches(row.tag, filter)andmatchesFilteris deleted. Not a changefor this PR — wave 1 is additive — but a decision worth writing down while the
reason is fresh.
Verified
Both commands, in
.worktrees/t3a-timew, reproduce what you reported:Command parity. I rebuilt your harness (
sedthereturnblock off eachside, append a tail so the chunk locals are in scope, stub
runAsyncto recordcmdandtimeout) and diffed the two sides. Fifteen invocations: the eightsites, the three tag-carrying ones repeated with a filter, and three repeated with
a hostile tag.
Identical byte for byte, including the awk programs,
seq 0 13, thecase/break,2>/dev/nullplacement, and all eight timeouts.Quoting, the safety ruling: safe.
shQuoteis textbook POSIX single-quoting(
'becomes'\''), which is total over every byte except NUL. I tried to breakout of it and could not. The tag shown below went through
rangeTotalanddayTotalsagainst a stubtimewonPATHthat prints its argv:One argv element, byte-identical to the input, no canary created. A separate
round-trip through
/bin/shwith a tag carrying an embedded newline, a literalrm -rfline, a backtick pair and a trailing backslash came back byte for byteunchanged, and left the canary file it named alive.
Every user-reachable interpolation is quoted: the tag in
dayTotals(299),rangeTotal(352), and the date inintervalsForDay(318).allIntervalsneverputs the tag in the command at all. The only raw interpolations are the numeric
bounds in finding 3, which no caller controls.
Parser parity. Same harness, feeding one canned stdout to each old callback
and to the matching parser, then comparing the module state against the return:
Identical on every row but
tags. The parsers are genuinely pure: they ran in aharness that never touched module state and never called
render.Structure.
git diff --numstat main refactor/t3a-timewreports35 0 test.luauand
204 0 timew/panel.luau: 239 insertions, zero deletions, so no live comment wasremoved. Only those two files are modified;
git ls-tree -ron the branch shows no*.md, nosecretspec.toml, noforge.sh. The table sits at 185–370, immediatelybefore
pokeBar()at 373.Timewis added to the return table, which the host ignores, so no handler name,settings key, or IPC event changed.
Quirk coverage, since you asked what the asserts miss. Blank lines,
(untagged), quoted multi-word tags, span-versus-prose, and empty output are allcovered. Three gaps. The
timew tagsheader and rule-width trick lives in the awkrather than the parser, so only finding 1 can reach it. The other two are parser
asserts you could add today:
parseDayTotalsis never fed timew's prose day(
2026-08-30 Sun No filtered data), which the^(%S+)%s+(%S+)%s+(%S+)$patterndrops rather than zeroes — the shell
caseis the only thing saving it, so anassert there documents why that
casemust never be deleted; andparseRangeTotalis asserted withNo filtered datawhere timew actually printsNo filtered data found in the range ….Answers
5a.
Timew.exec(cmd, cb)orTimew.start(tag)/Timew.stop()? Keepexec,and do not add the typed pair in T3b either. Inside
panel.luauthe pair wouldbe clean — three call sites (599, 745, 1176), all of which would lose their
shQuote. The reason not to isbar.luau:142, which builds the identical"timew start " .. shQuote(…)and"timew stop"with its ownshQuoteat line34, and cannot reach
panel.Timewbecause the plugin API forbidsrequirebetween entries. A typed pair would therefore exist for one of the two callers and
not the other: the tag's quoting moves inside the table for the panel and stays at
the call site for the bar, which is two idioms for one operation instead of one.
execalso mirrors bar'srunAsync(cmd .. " 2>&1", …)exactly. If you ever wantthe typed pair it has to be written into both entries, and that is a decision about
the no-require rule, not about this table.
5b.
walkMaxbounding the walk before the Luau tag filter: keep, and it isalready documented. The comment at 335–337 states the consequence precisely, and
the pairing with
rangeTotalis the honest mitigation: a truncated list stillreports a true total, because the number comes from timew rather than from the
rows. This is
main's behaviour, and preserving it is exactly the wave 1 contract.Please make sure T3b does not quietly "improve" it, since a rewire that changes
what the list contains is drift wearing a refactor's clothes. Worth its own issue,
and if it is ever fixed the fix is in timew, not in Luau: give the DOM walk a
timew getfiltered range so the bound applies after the tag, not before.5c. Counts and bounds as parameters: right for
tag, needless for the bounds.See finding 3. Default
days,walkMaxto the module constants and keep the tagarguments.
Also asked, on over-engineering. No
Timewfunction is unused: all sevenparsers feed a command function or an assert, and all eight command functions map
to a known caller.
tagMatchesearns its place overmatchesFilterfor the reasonin finding 5. There is no dead nil-callback path — every function calls
cb(…)unconditionally, with no
if cb thenguard, which is the right call. The onlyindirection I would cut is the bounds.
175d69f1a2tod39db8c727d39db8c727to38e891610dAll five findings taken, plus both optional parser asserts from your quirk-coverage
note. Rebased onto
e3d69aa, fixup pushed as38e8916. Two deviations from yourprototype and one correction to the review, below.
Finding 1 — command strings are now values, and asserted
Taken as prototyped.
Timew.tagArg(467) is extracted and exported, the duplicatedconditional is gone from both sites, and
Timew.cmd.<name>exists for all eight.The impure functions are now a
runAsync, a timeout and a parse, nothing else.I verified your module-globals claim independently before repeating it in a
committed comment, since the whole design rests on it:
Confirmed. A stub assigned in
test.luauis invisible insidepanel.luau, sothe strings had to become values rather than the consumer becoming stubbable.
Byte-identity, re-proved after the rebase and after the restructure. Same
harness shape you rebuilt:
returnblock cut off both sides, tail concatenatedon,
runAsyncstubbed to record. Twenty-one invocations now — the eight sites,the bound defaults, the tag-carrying ones with and without a filter, and each of
those again with the hostile tag. The new side drives the impure functions, not
the builders, so the timeout and the builder wiring are both under the diff.
Two of the twenty-one, quoted since they are the ones the finding is about:
Deviation 1: the bound defaults live in the builder, not the impure function.
Your sketch has
walkMax = walkMax or ALL_WALK_MAXas the first line ofTimew.allIntervals. That leavesTimew.cmd.allIntervals()producing[ "$i" -le nil ], so the frozen literal would have had to hard-code200supplied by the test rather than by the module constant — the assert would stop
being a check on the default. Putting the default in the builder makes the builder
total, keeps one place for the default, and lets the literal pin both:
Same for
days(seq 0 13in the frozen literal isDAYS - 1) andwalkMax.Deviation 2:
Timew.cmd.execexists too, so it is nine builders, not eight.It is one concatenation and on its own it would be ceremony. It is in because it
makes "every command string in this table comes out of
Timew.cmd" true with noexception, and because the
2>&1placement is exactly the class of thing youasked to freeze — it is what carries a failure message to the toast.
On the offer to defer this to T3b: declined, and thank you for making it.
Landing
Timew.cmdin T3b means the strings spend one commit unprotected in theexact window where the old ones get deleted, which is the risk the finding names.
Cheaper here.
Non-vacuity. Frozen literals are worthless if they cannot fail, so each new
assert was mutation-checked on a scratch copy:
Each fails, at the intended assert.
One thing your prototype's shape does not survive contact with: the day-totals
string cannot go through
string.format, because it is full ofdate(1)specifiers (
%Y,%m,%a) that a format eats. It is split around the tag andconcatenated instead.
Finding 2 —
parseTagsno-cap assertTaken verbatim, at 177. The comment on
parseTagsnow says the cap is thecaller's and that this assert is what enforces it, so the prose and the test
point at each other.
Finding 3 — bounds defaulted
Taken.
days or DAYS,walkMax or DAY_WALK_MAX,walkMax or ALL_WALK_MAX, inthe builders per deviation 1. The
tagparameters are untouched. Your reasoningis right and I had it backwards in the original description: the purity argument
never reached these three, since they call
runAsync.Finding 4 — line numbers
Taken, and it was worse than you found. Both columns were wrong by the time I
fixed them: PR #2 moved
main's fetch functions too, so theline on maincolumn — correct when you reviewed — is now also stale. The table is rebuilt with
both columns re-measured, a builder column added, and the two commits it is
relative to named, so the next reader can tell when it went stale.
The assert count was 20; it is now 43 across 129 added lines. Description says 43.
I made the same mistake once more while fixing it and want to flag the trap: I
first wrote
matchesFilter's call sites into a code comment as523, 820, 856,which are
main's numbers, not this branch's — theTimewtable is insertedabove them and shifts all three. Then correcting that comment grew it by a line
and shifted them again. They are
813, 1110, 1146on38e8916, verified afterthe final edit rather than before it.
Finding 5 —
matchesFilterdispositionTimew.tagMatcheswins;matchesFilteris deleted in T3b. Recorded in thecomment on
tagMatchesrather than only here, since that is where someone willbe standing when the question comes up. Not deleted now: it has three live call
sites and wave 1 only adds.
5a —
Timew.execstays a command stringAgreed with your ruling, and I checked the load-bearing part of it rather than
taking it:
bar.luau:215builds"timew stop"and"timew start " .. shQuote(…)with its own
shQuoteat 99, and cannot reachpanel.Timewbecause the APIforbids
requirebetween entries. A typed pair would serve one caller of two.The reasoning is now in the comment above
Timew.exec, including the instructionthat T3b should not add one either, so it does not get re-litigated by someone who
only sees the panel side.
Rebase
Conflicts were two, both purely additive: the export table, and the tail of
test.luau. Your Fmt/Parse/Query asserts and PR #1's bar asserts are untouched;mine are appended below them.
The real overlap was that my parsers reused this file's older free helpers for
jobs PR #2's pure core now does. Rather than ship two spellings,
Timew.parse*now calls
Parse.trim,Parse.unquote,Parse.clock,Parse.intervalandFmt.span. I diffed each pair over a shared corpus before switching, because"looks the same" is not the standard for something in the quoting path:
Corpus was the empty string, whitespace-only, unterminated and empty quotes,
apostrophes, malformed clocks (
1:2:3,::), a non-breaking space, embeddednewlines, ISO durations including bare
PandPT, and fractional and negativesecond counts. No pair differed, so none was kept back.
Then the end-to-end check, one canned stdout to each old callback and to the
matching parser, comparing module state against the return, plus
execagainstrunon exit 0, 1 and 127:tagsis still the only difference, still deliberate, and now asserted.Gate
Still zero deletions, still only those two files, no excluded file committed.