refactor(timew): hand runAsync argv arrays, delete shQuote #11
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "upgrade/u1-argv"
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?
U1 of the API-28 upgrade: every
noctalia.runAsynccall site that does not need ashell now hands the host an argv array, and
shQuoteis deleted from both entries.No
plugin_apibump — the argv form works at 3 (processArgsFromLua→startAsyncProcess→execvp, no/bin/shon that branch; the string branch isthe only thing that wraps).
grep -c shQuote timew/panel.luau timew/bar.luau→0and0.Call-site census
Ten
noctalia.runAsynccalls before, ten after — this changes what each is handed,not how many there are.
todayTotal,tags,rangeTotal,Timew.exec, bar'sonClicksh -cscript + positional datadayTotals,intervalsForDayactiveInterval,allIntervals, bar'ssyncFive shells remain, down from ten. Two of the five disappeared by restructuring
rather than by being kept (option b), and the tag/date in the two that survive
reaches them as
$1, so no script in this plugin contains user data any moreand no quoting helper survives under any name.
Why each remaining shell survives
Timew.cmd.activeInterval(panel),sync(bar)s=$(…),[ -n "$s" ],&&, nested$(date -d …)Timew.cmd.dayTotalsforoverseq, per-day$(…),case$1timew summarycalls with acasethat floors prose to0:00:00. As argv it is 14 subprocesses and 14 async landings to order. The tag is${1:+"$1"}, which also preserves "no tag adds no argument": with$1empty the loop that expands it iterates zero times.Timew.cmd.intervalsForDaywhilewithbreak,${l%%T*},case$1(d="$1")breakon ids that predate the day is the bound; without it the day view fetches 200 ids.Timew.cmd.allIntervalswhileoverdom.tracked.$iRestructured away (option b), both because the shell existed only to run an
awk:todayTotal:timew summary :day 2>/dev/null \| awk 'NF{l=$NF} END{print l}'→{"timew","summary",":day"}, with the awk asTimew.lastField.2>/dev/nullwent too — the callback exposesstderrseparately.tags:timew tags 2>/dev/null \| awk 'NR==3{w=length($1); next} NR>3 && NF{print substr($0,1,w)}'→{"timew","tags"}, with the column-width cut insideTimew.parseTags.rangeTotal: same awk astodayTotal; now{"timew","summary",":all"[, tag]}.Timew.exec/ bar'sonClick:cmd .. " 2>&1"→ argv, and the toast readstrim(stderr .. stdout).2>&1bought "the message reaches the user whichever stream carried it"; the concatenation buys the same thing with no shell. Both directions are asserted.Proof
Both gates:
The two Luau replacements are byte-identical to the awk they replace, run
against this machine's live timew:
The two surviving scripts produce identical output to the strings they replace,
old builder (from
HEAD~1) versus new script +$1, against live data:The hostile-tag assert
The same tag also runs through bar's
onClickloop, andDAY_SCRIPTis assertedto be the same constant with a tag and without one — which is the assert that
makes the surviving
sh -csites safe rather than merely quoted.Driven through the real
/bin/sh(n=0; for a in ${1:+"$1"}; do n=$((n+1)); printf "arg%s=[%s]\n" "$n" "$a"; done; printf "count=%s\n" "$n"):And the real
dayTotalsscript with that tag: 14 normal lines of zeros, emptystderr,
/still has its 18 entries.$HOMEunexpanded,`id`not executed,;rm -rf /not a command, the newline not a separator.What a reviewer should challenge
${1:+"$1"}vs the deletedtagArg. Both mean "no tag, no argument", but by different mechanisms.count=0above is the evidence; the alternative was an unconditional"$1"passing an empty argument totimew summary.stderr .. stdoutreplacing2>&1. Ordering differs from a live merge when both streams carry text; for a failingtimewonly one does. If you would rather branch (stderrif non-empty elsestdout), say so — it is two lines in each entry. Note the panel now readsresult.stderrunguarded, so a host that ever omitted the field would error; U0 verified the field is present on both branches.parseTagscounts records to find the rule on line 3, exactly as the awk did, so it iterates blank lines too (string.gmatch(stdout .. "\n", "([^\n]*)\n")). Iftimew tagsever grows or drops a leading blank line, the width lands on the wrong line — same fragility as the awk, now visible in Luau and covered by a generated fixture (tagsOutput) instead of a frozen post-awk string.parseTotalstill returns the last field verbatim when timew prints prose.timew summarywith no data yields e.g."found.", and it did before this change too. Preserved deliberately — behaviour parity, not an endorsement. ThecaseindayTotalsis what floors the chart.seq 0 13,-le 60,-le 200). They are module constants reached through arithmetic or a literal, not user data, so they stayed text; moving them to$2would have grown the diff without closing anything.test.luau'stakeJobnow takes a table needle (exact argv match) or a string needle (substring of the command joined with spaces). Day-walk landings that used to match ond='2026-08-31'now useT.cmd.intervalsForDay(date)— a stronger match than the old substring.noctalia.runAsync accepts an argv table and passes it straight to execvp with no shell anywhere on that branch (processArgsFromLua -> startAsyncProcess -> execvp), so the commands that never needed a shell now hand it a list and a tag travels as one element. shQuote is deleted from both entries; nothing replaced it, because there is nothing left to quote. Four commands are genuine shell programs and stay scripts: the active-interval conditional, the two DOM walks, and the 14-day summary loop. The two of those that carry data take it as a positional parameter -- {"/bin/sh", "-c", script, "sh", value} -- so every script in the plugin is now a constant string that no interpolation touches. Two shells went away instead of being kept: the awk behind `timew summary`'s pipe is Timew.lastField, and the awk that cut the `timew tags` column is Timew.parseTags. Both were checked byte-for-byte against the pipelines they replace on live timew data. The mutating path's `2>&1` is gone too -- the callback delivers stderr separately, so the toast reads `stderr .. stdout`. test.luau pins argv element by element rather than freezing a command string, and a hostile tag (space, both quote characters, $HOME, backticks, a semicolon and a newline) is asserted to arrive as exactly one element and to appear nowhere in any script's text.Verdict: REQUEST_CHANGES
The conversion itself is right and I could not break it.
${1:+"$1"}is byte-for-bytethe old
tagArgunder both/bin/shshells I ran it through, the two Luau parsers agreewith the awk they replaced on every input timew can produce, the hostile-tag assert is
load-bearing rather than decorative, and both gates are green from a clean run. What
blocks is on the test side: two of the five surviving mutations land on lines this PR
wrote, and one of them contradicts a comment this PR also wrote.
18 mutations, 5 survivors. Full table at the bottom.
Blocking
1.
test.luau:476-483claims a drift check it does not performThe comment says the two files' spellings of
timew start <tag>"are the one pairnothing shares. This is what catches them drifting." It does not. On
mainthe sameassert read
which compared bar's command against panel's own builders, so panel drifting moved the
expected value. The replacement compares bar's argv against a literal written in the test,
and panel's side is asserted nowhere at all.
startTag(panel.luau:891) is the one callsite in the panel that puts user data into an argv array, and no test reaches it:
M5 survives because
takeJob("timew stop")(test.luau:1338) is a string needle andtable.concat({"timew","stop","--dry"}, " ")contains"timew stop". That weaknesspredates the PR, but the PR introduces the exact-argv needle that closes it and converts
every sibling to it, so this one is a missed conversion rather than an inherited limit.
Fix, at
test.luau:1338:and drive one start through the panel so
startTagis reached at all —onIpc("start", HOSTILE)then
argvIs(takeJob("timew start").cmd, { "timew", "start", HOSTILE }). That restores thecross-file pin the comment promises and kills M5, M12 and M12b together.
2.
panel.luau:709— the newresult.stderr .. result.stdouthas no testTimew.execis the only path from a failedtimew start/timew stopto a toast, andits body is new in this PR. Nothing exercises it:
Two reasons it survives.
test.luau:589stubs the panel'snotifyErrorasfunction() end, so the argument is discarded, and no panel job is ever landed with anon-zero
exitCode—test.luau:1338lands{ exitCode = 0, stdout = "" }.This matters more than a coverage gap because I confirmed against timew 1.9.1 that a real
failure puts its message on stderr only:
So if that concatenation ever regresses to
result.stdout, every panel error toast goesblank and the suite stays green. The line is correct today; it just is not defended.
Fix — capture the argument in the panel stub and land one failure, mirroring
test.luau:494-503:Advisory
3.
panel.luau:709,bar.luau:221— a missingtimewnow toasts an empty stringPatch-introduced, and the only user-visible behaviour change I found. On
mainthecommand went through a shell, so the shell's own diagnostic was folded into stdout by the
2>&1that this PR deletes:With argv there is no shell to say that. U0's live probe of this host has
{"definitely-not-a-binary-xyz"}returningexit=127withstdout=""andstderr=""(the host does::_exit(127)after a failedexecvp,process.cpp:410), soParse.trim(result.stderr .. result.stdout)is""and the toast body is empty. Thetimeout case (
exitCode 143) was already blank on both sides, so that half is not aregression.
dependencies = ["timewarrior"]covers the install-time case but not a PATH the shelldoes not share. A fixed fallback is two lines:
noctalia.commandExists("timew")is also a real binding at this API if a preflight checkreads better than a fallback string.
4.
test.luau:664-684— three string needles left, and one of them hides a real gaptakeJob("seq 0 13")(:828,:1164,:1339,:1534,:1767) finds the day-totals jobby a substring of its script, so it matches whatever tag the job carries:
That mutation removes the behaviour the README documents ("scopes the chart and the totals
to that tag") and the suite does not notice.
T.cmd.dayTotals(DAYS, "focus")as a tableneedle pins it. Same class as finding 1; separate because this one is inherited rather
than introduced.
Worth noting while you are in
takeJob: the table branch comparescmdText(job.cmd) == cmdText(needle)plus the element count, not element by element.{"a","b c"}and{"a b","c"}both join to"a b c"with count 2.argvIsalready doesthe element-wise comparison — reusing it inside
takeJobcosts nothing and removes thecase entirely.
5.
panel.luau:392-397— "every script in this file is a constant string" is not quite trueTwo numbers are still interpolated:
(days or DAYS) - 1at:571and(walkMax or DAY_WALK_MAX)/(walkMax or ALL_WALK_MAX)at:597and:617. The firstis forced numeric by the subtraction; the two
walkMaxsites are raw concatenation, so astring argument would be spliced into the script verbatim. Both call sites pass module
locals (
loadDayat:832,fetchAllat:847), so nothing is reachable today — butthe comment is the file's central safety claim and a future caller will read it as a
guarantee. Either narrow the sentence to "no user data", or make it true:
6.
panel.luau:445-448—parseTotalstill returns prose (pre-existing, parity preserved)Recording it because this PR rewrote the function and added its fixtures. Real timew on an
empty day prints prose on stdout, and the last field of it is a timestamp:
Both spellings agree, so this is not a regression —
mainshowed the same string. ButparseRangeTotal(:521-523) guards with^%d+:%d%d:%d%d$andparseTotaldoes not, andthe new
SUMMARYfixture attest.luau:180is the natural place to add the prose case nowthat the reduction is Luau.
Verified
Everything below I ran; nothing is quoted from the PR description.
Gates, from a clean run in
.worktrees/u1-argv:Counts, derived not taken.
shQuoteis 0 intimew/panel.luauand 0 intimew/bar.luau;the 2 hits in
test.luauare both prose (:439,:468). No re-implementation under anothername: grepping
gsub(.*',string.format("%q",\\"acrosstimew/andtest.luaureturnsonly
shown()attest.luau:276(a%qformatter for assert messages) anddumpValueat:927.tagArgandTimew.cmd.execsurvive only in comments — clean cutover.10
noctalia.runAsyncsites (8 panel, 2 bar); 5 reach a shell:activeIntervalandallIntervalsas strings,bar.syncas a string,dayTotalsandintervalsForDayas/bin/sh -cargv. Matches the claim.Injection is structural, by inspection of all five.
activeInterval,allIntervalsandbar.synctake no arguments at all.dayTotalsandintervalsForDaybuild their script fromliterals plus one numeric bound and pass data as
argv[5]. No user-controlled value isconcatenated into any script text.
${1:+"$1"}against the deletedtagArg, driven through a real shell with a stubtimewthat dumps its own argv. 17 tags, plus the no-tag and unset cases, under bash 5.3.15 as
/bin/shand under dash. Identical argv in every case, including*,~,$HOME,a bare
-,-x,--all, a single space, a tab, and the multiline hostile tag:argc=4for both nil and""is the "no tag adds no argument" rule, preserved exactly.Parser parity, 26 corpus cases against GNU awk 5.4.1. Old side is the real pipeline —
awk 'NF{l=$NF} END{print l}'thenParse.trimforlastField, andawk 'NR==3{w=length($1); next} NR>3 && NF{print substr($0,1,w)}'then the old trim-and-droploop for
parseTags. Corpus includes realtimew tagsandtimew summaryoutput, the empty-rangeprose, empty input, blank-only, whitespace-only lines, trailing whitespace, CRLF, tabs, a
missing trailing newline,
Learn C++, a tag wider than the rule, a rule with a leadingspace, a blank line mid-list, and a header with no rows.
Only
\fand\vmid-line diverge, because Lua's%scounts them and awk's default fieldsplitting does not. timew emits neither. CRLF does not diverge, because
mainranParse.trimover awk's output and Lua's%salready ate the\r.Real timew 1.9.1 shapes, against a scratch
TIMEWARRIORDB.timew tagsis a blank line, aheader, a rule whose first dash run is the Tag column width, then padded rows — which is what
parseTagsassumes. An empty database givesNo data found.on stdout (one line, rc=0);parseTagsreturns{}for it, as does the awk. Every failure I could construct put itsmessage on stderr with stdout empty; every success the reverse. I could not produce a
timew failure that writes to both streams, so the
stderr .. stdoutordering is notobservable and is not a regression.
Argv element types. Nothing non-string can reach an array:
startTagrunsParse.trimfirst (:887),setFilterrunsParse.trim(:902),Config.get("tag")is stringified (
test.luau:439), andloadDayreturns early whenstate.days[index]isabsent (
:830-834), sointervalsForDaynever sees a nil date.Not checked. I did not run the plugin inside a live noctalia shell — the host contract
(
result.stderralways a string,execvpPATH search,::_exit(127)on a missing binary,timedOutas a separate boolean) is U0's live probe plus its source citations, not my own.I did not re-verify the surviving
dayTotalsawk under mawk or busybox awk, since the PRdoes not touch it. I did not look at the panel on screen; every check here is the suite,
the shell, and timew. I did not exercise the 15 s / 10 s / 20 s timeouts.
Mutations
Applied to a copy of the worktree, one at a time, suite re-run and reverted after each.
dayTotals: splicetagback into the script text (real concatenation, not a literal edit)test.luau:311DAY_SCRIPTsees it311-317,321-330,350-354)test.luau:359intervalsForDay: splicedateback into the script text:321, and:359after the same neutering${1:+"$1"}->"$1"(an empty tag now adds an empty argument):311tag or ""->tag(argv loses its 5th element):283argvIs'#argv == #expectedis realonStop/onIpc->run({"timew","stop","--dry"})Timew.exec: dropresult.stderrfrom the toastbar.onClick: dropresult.stderrfrom the toast:498rangeTotal: insert the tag unconditionally:283lastField: first field of each line instead of the last:189SUMMARYfixture is not decorationparseTags: read the width off row 2 instead of row 3:215tagsOutputfixture bitesfetchDays: stop passingstate.filterstartTag: drop the tag from the argvstartTag->{"timew","start","--tag",tag}intervalsForDay:"sh"->"", shifting the date onto$0:311$0placeholder is pinnedtodayTotal::day->:week:295lastField: drop theif fieldguard:190loadDay: passday.date .. " ":684dayJob's table needle pins the dateAddressed in
58a0b0b. All four blocking/advisory findings taken, plus 5 and 6. Onepartial pushback on 5 and one place where I could not produce the evidence you would
want, both below.
The five mutants you reported as survivors survive on the reviewed tree and are killed
on this one. Re-run against
git show HEAD~1for all three files, so the "before"column is the tree you actually reviewed:
1 — the drift check the comment promised
You were right that the comment described a check the assert did not perform, and right
about the mechanism: a literal cannot drift, so it cannot catch the panel drifting.
Both mutating commands are pinned across the two entries now. bar's arrays are kept as
they are built (
START_TAGS/barStart,test.luau:502) and the panel's are drivenagainst them at the end of the file, which is the first point
panelJobsexists.I did not take the
takeJob("timew start")half of your suggestion, and the reason isthe finding itself. A needle spelling out
{"timew","start",tag}is another literal —it would be satisfied by bar and the panel drifting together, and a
takeJobthatmisses raises "no queued command matching", which is a failure with a message that
names the needle rather than the disagreement. So the panel's array is taken off the
queue positionally instead,
#panelJobs == 1and thenargvIs(panelJobs[1].cmd, barStart[tag]). Neither side is compared to anything written in the test:That runs over all six tags including the hostile one. Stop is the same shape, with bar
driven to tracking first so its own argv is the comparand.
2 —
Timew.exec's toastTaken as written: the panel's
notifyErrorstub captures intopanelError, and thefailure path is driven for eight result shapes (stderr-only, stdout-only, both, blank,
127 blank, 127 with text, 143 blank) through
onIpc("start", …), becauserunreportsthrough
Timew.execand refetches either way. Plus the negative: exit 0 toasts nothing,or the loop above would pass on a panel that notified on everything.
3 — the blank toast for a missing timew
Fallback string, not a
commandExistspreflight, and not both. The reasoning:you found, but a preflight only closes that one; any other silent failure still
toasts nothing. The fallback closes the class at the point where the body is chosen.
one, to answer a question the failing call answers for free.
commandExistscannot see the case it is supposed to guard: the point of your findingis a PATH the shell does not share, and a preflight reads the same PATH
execvpwill.
It is
Parse.toast(result)in the panel andtoast(result)in bar, line for lineidentical apart from the trim being a field on one side and a local on the other:
127 is named because it is the one a user can act on — "timew is not on PATH" is the
command not foundline2>&1used to carry, said by the plugin instead of the shell.Anything else silent carries its exit code, which is strictly better than the blank both
mainand the reviewed tree produced for a timeout. Text on either stream still wins, sonothing about the normal failure path changed.
Untranslated, deliberately:
Parsereaches no host, which is what makes it assertablewithout a stub, and the timew text it stands in for was never translated either.
Because it is pure and duplicated, it is pinned both ways — the two copies against each
other over every shape, and then against what they actually say, since two identical
copies of a regression agree just as well:
4 — the string needles
All of them, not just the one you named.
takeJobtakes an exact needle now: a table ismatched by
argvIselement by element, a string — the two commands that really areshell scripts — is matched whole.
cmdTextsurvives only to build the failure message,which now dumps the queue.
Converted:
"seq 0 13"×5,"summary :day"×2,"summary :all"×2,"dom.active.start","timew stop",WALK,ANY_DAY.grep 'takeJob("'returns only prose.Day totals go through
daysJob()(T.cmd.dayTotals(nil, panel.state.filter)) where thetag is incidental, and through the literal
T.cmd.dayTotals(nil, "focus")at the sitethat documents "the chart follows the tag". One conversion turned out to be landing the
wrong job: at
:915the needle used to take the oldest queued day-totals job, issuedunder
"focus"several filters earlier, not the one row 2 had just asked for.ANY_DAYisT.cmd.intervalsForDay(ANY)— anANYsentinel in the date slot only, sothe script, the
shthat puts the date on$1and the element count all stay pinned.Three mutations to
loadDayconfirm it did not become "match anything":Where I owe you a mutation and do not have one. You also noted the table branch
compared joined text plus element count, so
{"a","b c"}and{"a b","c"}matched. Itnow reuses
argvIs, as you suggested. I could not produce a mutation that distinguishesthe two comparisons, and I think the class is unreachable rather than merely hard: every
table needle in the suite is built by the same
Timew.cmdbuilder as the job it matches,so a builder change moves needle and job together. Demonstrated by neutering the frozen
WALK_SCRIPTasserts and then breaking the builder:Both are caught immediately with those asserts left in place. So the division of labour
is: the frozen literals pin the builders, the needles pin the call sites, and neither
substitutes for the other — which is your finding 1 from the other direction. I took the
change because it costs nothing and removes a latent trap, not because I can show it
firing.
5 — "every script in this file is a constant string"
Narrowed rather than made true, taking the first of the two options you offered:
I did not add the
math.floor(tonumber(…))coercion.tonumber(walkMax) or DAY_WALK_MAXalso changes behaviour in the case it is meant to guard: a non-numeric string would
silently become the default instead of failing, which is a worse answer than the
type error a caller deserves. No caller outside the file can reach these —
loadDayandfetchAllpass module locals — so the coercion would be defence against a caller thatcannot exist, and the comment is now accurate about which values are text. Verified by
grep: the only concatenations into script text are the three bounds at
:596,:622and
:642.6 —
parseTotalreturns proseTaken. The prose case is asserted beside the
SUMMARYfixture, with your exact input,as behaviour rather than endorsement — including the reason it is harmless, which is that
Parse.clockreads it as 0 downstream:So a future change that "fixes" it by adding
parseRangeTotal's guard now has to failthis line first and say so:
Mutations
20 mutations, 20 killed, 0 survivors. Each applied to a throwaway copy so the branch is
never edited; the three files' sha256 are byte-identical before and after the whole run,
and the suite is green in the branch afterwards. The two labelled
'are the neuteredruns above, reported as survivors on purpose.
Parse.toastdropsresult.stderrParse.toastreturns""instead of the fallbackParse.toast: exit 127 loses its nametoastcopy dropsresult.stderronClickbypassestoast(your M7)Timew.execbypassesParse.toast(your M6)Timew.exectoasts on success toostartTagdrops the tag (your M12)startTagdrifts to--tag <tag>(your M12b)timew stop --dry(your M5)fetchDaysstops passingstate.filter(your M11)parseTotalfloors prose the wayparseRangeTotaldoesloadDayasks for the wrong dateloadDayasks with a non-defaultwalkMaxloadDaywalks every interval instead of the dayfetchAllasks for a one-day walkfetchAllwalks 60 ids instead ofALL_WALK_MAXfetchActiveasks for the day totaltodayTotalasks:allinstead of:dayfetchAll's range total drops the tagintervalsForDay:"sh"->"bash", frozen walk asserts neuteredintervalsForDay: drop thewalkMaxbound, same neuteringGates
git status --porcelainis empty in the worktree.Diff
50 insertions and 3 deletions in the two entries; the rest is the suite.
Nothing about the argv conversion itself changed. The
Parse.toast/toastpair is theonly behavioural difference from the reviewed tree, and it only ever fires where the
toast was previously blank.