From 799aa4b4325db458110a81074b9b06e6e698a588 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 10:47:50 -0700 Subject: [PATCH 01/50] shape: hold the lexer's per-word state in one value The lexer kept the word being built in five loose variables and reset them by hand: flush cleared started, qat, opaque and ticks one at a time and begin set glued in a second closure. A field added to a token and missed in that list carries one word's quoting, expansion or backtick count into the next word, and nothing would notice. The per-word state is now a word struct (text, started, glued, quotedAt, opaque, ticks), reset whole with newWord when a word is emitted. The bytes between words, gap and sawNL, stay with lex because they belong to no word. Behaviour is unchanged: every tokenizer, program and masked-status test passes as before. TestRubricLLD9_WordResetClearsEveryField sets every field of word to a non-zero value through reflection, so a field added later is covered without editing the test, and requires reset to give the fresh word. Mutants: a field-by-field reset that forgets ticks and glued, and a fresh word not quoted at -1 (RUBRIC_lld-9_word-reset.py). The H-13, MS and tokenizer mutants that anchored on the loose variables are re-anchored on the struct fields and are still red. Rubric fix list entry 66 (lld-9). --- internal/shape/tokenize.go | 86 +++++++++++-------- internal/shape/word_test.go | 43 ++++++++++ test/mutation/mutants/H-13_shape.py | 16 ++-- test/mutation/mutants/H-3_install.py | 2 +- test/mutation/mutants/MS_masked.py | 2 +- .../mutants/RUBRIC_lld-9_word-reset.py | 11 +++ 6 files changed, 113 insertions(+), 47 deletions(-) create mode 100644 internal/shape/word_test.go create mode 100644 test/mutation/mutants/RUBRIC_lld-9_word-reset.py diff --git a/internal/shape/tokenize.go b/internal/shape/tokenize.go index 1ce6dbb7..530d41b5 100644 --- a/internal/shape/tokenize.go +++ b/internal/shape/tokenize.go @@ -70,6 +70,27 @@ type token struct { ticks int } +// word is the lexer's state for the word being built: everything a token +// carries that is a property of that word alone. Emitting the word resets +// all of it at once (reset), so a field added here is cleared with the rest. +// The bytes between words -- whether a blank or a newline came before the next +// one -- are not a word's, and stay with lex. +type word struct { + text strings.Builder + started bool + // glued: the word began with no blank or newline before it (token.glued). + glued bool + quotedAt int // token.quotedAt: -1 until quoting touches the word + opaque bool + ticks int +} + +// newWord is the state before a word's first byte. +func newWord() word { return word{quotedAt: -1} } + +// reset makes w the state before the next word's first byte. +func (w *word) reset() { *w = newWord() } + // tokenizeMarked is tokenize plus one bit per token: whether the tokenizer // emitted it for an unquoted metacharacter. // @@ -157,16 +178,11 @@ func tokenizeList(s string) ([]token, error) { // docs selects tokenizeList's reading of here-documents and comments. func lex(s string, program, docs bool, joins []int) ([]token, error) { var ( - toks []token - cur strings.Builder - started bool - qat = -1 - opaque bool - ticks int - sawNL bool - // gap: a blank or newline since the last token ended. glued: the - // word being built began with no gap before it. - gap, glued bool + toks []token + w = newWord() + sawNL bool + // gap: a blank or newline since the last token ended. + gap bool // dollarAt: the index of the last unquoted, unescaped `$`, and // dollars: how many of them run together up to it. dollarAt = -1 @@ -183,15 +199,15 @@ func lex(s string, program, docs bool, joins []int) ([]token, error) { // begin marks the current word started, noting at its first byte // whether it is glued to the token before it. begin := func() { - if !started { - glued = !gap && len(toks) > 0 - started = true + if !w.started { + w.glued = !gap && len(toks) > 0 + w.started = true } } // markQuoted records where quoting first touched the current token. markQuoted := func() { - if qat < 0 { - qat = cur.Len() + if w.quotedAt < 0 { + w.quotedAt = w.text.Len() } } // expands reports whether s[i] opens an expansion this tokenizer does @@ -214,21 +230,17 @@ func lex(s string, program, docs bool, joins []int) ([]token, error) { // unterminated is the error for a quote that never closes. For the // program search, after an expansion it is uncertain: see lex. unterminated := func() error { - if program && (opaque || expanded) { + if program && (w.opaque || expanded) { return errUncertain } return errUnterminated } flush := func() { - if started { - toks = append(toks, token{text: cur.String(), quotedAt: qat, opaque: opaque, nlBefore: sawNL, glued: glued, ticks: ticks}) - expanded = expanded || opaque + if w.started { + toks = append(toks, token{text: w.text.String(), quotedAt: w.quotedAt, opaque: w.opaque, nlBefore: sawNL, glued: w.glued, ticks: w.ticks}) + expanded = expanded || w.opaque sawNL, gap = false, false - cur.Reset() - started = false - qat = -1 - opaque = false - ticks = 0 + w.reset() } } @@ -261,7 +273,7 @@ func lex(s string, program, docs bool, joins []int) ([]token, error) { i = e } - case docs && c == '#' && !started: + case docs && c == '#' && !w.started: for i+1 < len(s) && s[i+1] != '\n' { i++ } @@ -275,7 +287,7 @@ func lex(s string, program, docs bool, joins []int) ([]token, error) { if s[i] != '\n' { // a backslash-newline is a line continuation begin() markQuoted() - cur.WriteByte(s[i]) + w.text.WriteByte(s[i]) } case c == '\'': @@ -293,7 +305,7 @@ func lex(s string, program, docs bool, joins []int) ([]token, error) { } begin() markQuoted() - cur.WriteString(s[i+1 : i+1+j]) + w.text.WriteString(s[i+1 : i+1+j]) i += j + 1 case c == '"': @@ -312,14 +324,14 @@ func lex(s string, program, docs bool, joins []int) ([]token, error) { if s[i] == '\\' && i+1 < len(s) { switch n := s[i+1]; n { case '"', '\\', '$', '`': - cur.WriteByte(n) + w.text.WriteByte(n) i++ continue case '\n': i++ continue } - cur.WriteByte('\\') + w.text.WriteByte('\\') continue } if s[i] == '"' { @@ -336,18 +348,18 @@ func lex(s string, program, docs bool, joins []int) ([]token, error) { if end < 0 { return toks, errUncertain } - opaque = true - cur.WriteString(s[i : end+1]) + w.opaque = true + w.text.WriteString(s[i : end+1]) i = end continue } if expands(i, true) { - opaque = true + w.opaque = true } if s[i] == '`' { - ticks++ + w.ticks++ } - cur.WriteByte(s[i]) + w.text.WriteByte(s[i]) } if !closed { return toks, unterminated() @@ -378,7 +390,7 @@ func lex(s string, program, docs bool, joins []int) ([]token, error) { default: if expands(i, false) { - opaque = true + w.opaque = true } if c == '$' { if dollarAt == i-1 { @@ -389,11 +401,11 @@ func lex(s string, program, docs bool, joins []int) ([]token, error) { dollarAt = i } if c == '`' { - ticks++ + w.ticks++ grouped = true } begin() - cur.WriteByte(c) + w.text.WriteByte(c) } } if len(pending) > 0 { diff --git a/internal/shape/word_test.go b/internal/shape/word_test.go new file mode 100644 index 00000000..69dd3325 --- /dev/null +++ b/internal/shape/word_test.go @@ -0,0 +1,43 @@ +package shape + +import ( + "reflect" + "testing" + "unsafe" +) + +// TestRubricLLD9_WordResetClearsEveryField: the lexer's per-word state is one +// value, and emitting a word resets all of it at once. Every field of word is +// set to a non-zero value through reflection -- so a field added later is set +// too, without editing this test -- and reset must leave exactly the fresh +// word. The hand-written reset this replaced cleared four fields by name in +// one closure and set a fifth in another, and a field missed there carries +// one word's quoting, expansion or backtick count into the next. +func TestRubricLLD9_WordResetClearsEveryField(t *testing.T) { + var w word + v := reflect.ValueOf(&w).Elem() + for i := 0; i < v.NumField(); i++ { + sf := v.Type().Field(i) + f := reflect.NewAt(sf.Type, unsafe.Pointer(v.Field(i).UnsafeAddr())).Elem() + switch f.Kind() { + case reflect.Bool: + f.SetBool(true) + case reflect.Int: + f.SetInt(7) + case reflect.Struct: + if sf.Type != reflect.TypeOf(w.text) { + t.Fatalf("word.%s is a %s; teach this test to set it", sf.Name, sf.Type) + } + w.text.WriteString("carried") + default: + t.Fatalf("word.%s is a %s; teach this test to set it", sf.Name, sf.Type) + } + } + w.reset() + if fresh := newWord(); !reflect.DeepEqual(w, fresh) { + t.Fatalf("after reset the word is %+v, want the fresh word %+v: a field survived into the next word", w, fresh) + } + if w.text.Len() != 0 || w.quotedAt != -1 { + t.Fatalf("after reset: %d bytes of text, quotedAt %d; want none and -1", w.text.Len(), w.quotedAt) + } +} diff --git a/test/mutation/mutants/H-13_shape.py b/test/mutation/mutants/H-13_shape.py index dfc11914..f78afff6 100644 --- a/test/mutation/mutants/H-13_shape.py +++ b/test/mutation/mutants/H-13_shape.py @@ -7,8 +7,8 @@ m("H-13 a word the tokenizer cannot vouch for is read as the program", "internal/shape/shape.go", "\t\tcase t.opaque, isComment(t):", "\t\tcase isComment(t):", "TestNoCorpusLineLeaksIntoProgram") m("H-13 the tokenizer never marks an expansion opaque", "internal/shape/tokenize.go", - "\t\tdefault:\n\t\t\tif expands(i, false) {\n\t\t\t\topaque = true\n\t\t\t}", - "\t\tdefault:\n\t\t\tif expands(i, false) {\n\t\t\t\t_ = opaque\n\t\t\t}", "TestNoCorpusLineLeaksIntoProgram") + "\t\tdefault:\n\t\t\tif expands(i, false) {\n\t\t\t\tw.opaque = true\n\t\t\t}", + "\t\tdefault:\n\t\t\tif expands(i, false) {\n\t\t\t\t_ = w.opaque\n\t\t\t}", "TestNoCorpusLineLeaksIntoProgram") m("H-13 backslash-newlines are not joined before the program search", "internal/shape/shape.go", "\tif !strings.Contains(s, \"\\\\\\n\") {\n\t\treturn s, nil\n\t}", "\tif true {\n\t\treturn s, nil\n\t}", "TestNoCorpusLineLeaksIntoProgram|TestTokenizeProgram") @@ -42,7 +42,7 @@ m("H-13 the tokenizer never marks an operator glued", "internal/shape/tokenize.go", "nlBefore: sawNL, glued: !gap && len(toks) > 0})", "nlBefore: sawNL, glued: false})", "TestTokenizeGlued|TestProgramIsAProgram") m("H-13 the tokenizer never marks a word glued", "internal/shape/tokenize.go", - "\t\t\tglued = !gap && len(toks) > 0\n", "\t\t\tglued = false\n", "TestTokenizeGlued|TestProgramIsAProgram") + "\t\t\tw.glued = !gap && len(toks) > 0\n", "\t\t\tw.glued = false\n", "TestTokenizeGlued|TestProgramIsAProgram") m("H-13 a byte the shell does not split on is read as part of a program name", "internal/shape/shape.go", "\t\tcase c < 0x21 || c > 0x7e:", "\t\tcase c == ' ' || c == '\\t' || c == '\\n' || c == '\\r':", PROG) m("H-13 a command word holding $ is named", "internal/shape/shape.go", @@ -151,9 +151,9 @@ # Re-anchored after the masked-status refactor: the backtick case also marks the # line grouped for the list walk's here-document reading; only the count goes. m("H-13 the tokenizer never counts an unquoted backtick", "internal/shape/tokenize.go", - "\t\t\tif c == '`' {\n\t\t\t\tticks++\n\t\t\t\tgrouped = true\n\t\t\t}\n", "\t\t\tif c == '`' {\n\t\t\t\tgrouped = true\n\t\t\t}\n", "TestTokenizeTicks|" + PROG) + "\t\t\tif c == '`' {\n\t\t\t\tw.ticks++\n\t\t\t\tgrouped = true\n\t\t\t}\n", "\t\t\tif c == '`' {\n\t\t\t\tgrouped = true\n\t\t\t}\n", "TestTokenizeTicks|" + PROG) m("H-13 the tokenizer never counts a backtick inside double quotes", "internal/shape/tokenize.go", - "\t\t\t\tif s[i] == '`' {\n\t\t\t\t\tticks++\n\t\t\t\t}\n", "", "TestTokenizeTicks") + "\t\t\t\tif s[i] == '`' {\n\t\t\t\t\tw.ticks++\n\t\t\t\t}\n", "", "TestTokenizeTicks") # Judged by the corpus alone, where the two lines of the PR #29 review are the # only ones this break reopens: "$(echo "a;b")" ends at its first inner quote # again, and the ; inside it ends the search for the () after the command word. @@ -260,8 +260,8 @@ m("H-13 an odd run of $ is read as bash's $$'", "internal/shape/tokenize.go", "\t\t\t\tif dollars%2 == 0 {", "\t\t\t\tif dollars > 1 {", "TestTokenizeProgramReadsANSICQuotes") m("H-13 an unterminated quote after an expansion is read as a real one", "internal/shape/tokenize.go", - "\t\tif program && (opaque || expanded) {", "\t\tif false {", "TestTokenizeProgramStops|" + PROG) + "\t\tif program && (w.opaque || expanded) {", "\t\tif false {", "TestTokenizeProgramStops|" + PROG) m("H-13 an expansion in an earlier word leaves a later quote certain", "internal/shape/tokenize.go", - "\t\tif program && (opaque || expanded) {", "\t\tif program && opaque {", "TestTokenizeProgramStops|" + PROG) + "\t\tif program && (w.opaque || expanded) {", "\t\tif program && w.opaque {", "TestTokenizeProgramStops|" + PROG) m("H-13 the count's tokenizer stops where the program search does", "internal/shape/tokenize.go", - "\t\tif program && (opaque || expanded) {", "\t\tif opaque || expanded {", "TestTokenize") + "\t\tif program && (w.opaque || expanded) {", "\t\tif w.opaque || expanded {", "TestTokenize") diff --git a/test/mutation/mutants/H-3_install.py b/test/mutation/mutants/H-3_install.py index d03e4bda..650a8589 100644 --- a/test/mutation/mutants/H-3_install.py +++ b/test/mutation/mutants/H-3_install.py @@ -9,7 +9,7 @@ "\t\tcase isMeta(c):\n", "\t\tcase isMeta(c) && false:\n", "TestTokenize") m("tokenizer emits the token an unterminated quote interrupted", "internal/shape/tokenize.go", "\t\t\tif !closed {\n\t\t\t\treturn toks, unterminated()", - "\t\t\tif !closed {\n\t\t\t\tstarted = true\n\t\t\t\tflush()\n\t\t\t\treturn toks, unterminated()", "TestTokenize") + "\t\t\tif !closed {\n\t\t\t\tw.started = true\n\t\t\t\tflush()\n\t\t\t\treturn toks, unterminated()", "TestTokenize") m("settings accepts a duplicate key", "internal/settings/document.go", "\t\tif seen[key] {\n\t\t\treturn nil, fmt.Errorf(\"duplicate key %q\", key)\n\t\t}\n\t\tseen[key] = true", "\t\tseen[key] = true", "TestParseRefuses|TestHookEntriesRefusesDuplicateEventKeys") diff --git a/test/mutation/mutants/MS_masked.py b/test/mutation/mutants/MS_masked.py index 9af879d3..0224e85a 100644 --- a/test/mutation/mutants/MS_masked.py +++ b/test/mutation/mutants/MS_masked.py @@ -114,7 +114,7 @@ m("MS here-document bodies are read as commands", "internal/shape/tokenize.go", "\t\t\t\tpending = append(pending, j)\n", "\t\t\t\t_ = j\n", SH) m("MS a comment is read as words in the list walk", "internal/shape/tokenize.go", - "\t\tcase docs && c == '#' && !started:", "\t\tcase false && docs && c == '#' && !started:", SH) + "\t\tcase docs && c == '#' && !w.started:", "\t\tcase false && docs && c == '#' && !w.started:", SH) m("MS the runner digest keeps a redirection's target", "internal/shape/masked.go", "\t\t\tj = operatorEnd(toks, j) + 1\n", "\t\t\tj = operatorEnd(toks, j)\n", SH) m("MS the runner digest runs to the end of the line", "internal/shape/masked.go", diff --git a/test/mutation/mutants/RUBRIC_lld-9_word-reset.py b/test/mutation/mutants/RUBRIC_lld-9_word-reset.py new file mode 100644 index 00000000..1b7c7d14 --- /dev/null +++ b/test/mutation/mutants/RUBRIC_lld-9_word-reset.py @@ -0,0 +1,11 @@ +# rubric lld-9 (fix list 66): the lexer's per-word state is one value, reset +# whole when a word is emitted. Going back to a field-by-field reset that +# forgets one field is the regression; the reflection test sets every field. +m("RUBRIC lld-9 the word is reset field by field and forgets its backticks", "internal/shape/tokenize.go", + "func (w *word) reset() { *w = newWord() }", + "func (w *word) reset() { w.text.Reset(); w.started, w.glued, w.quotedAt, w.opaque = false, false, -1, false }", + "TestRubricLLD9_") +m("RUBRIC lld-9 a fresh word is not quoted at -1", "internal/shape/tokenize.go", + "func newWord() word { return word{quotedAt: -1} }", + "func newWord() word { return word{} }", + "TestRubricLLD9_|TestTokenize") From a3090645328d6ce783b25309802132cd40ff307f Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 10:55:18 -0700 Subject: [PATCH 02/50] shape: stop lexing at a token bound derived from Derive's cost per token A hook payload is read up to 8 MiB, which bounds the bytes and not the work. 8 MiB of one-letter words is four million shell words, and the shape of that one line allocated 4.5 GB (TotalAlloc, measured in the new test before the bound) and drove a hook to about 1.07 GB of RSS. Lexing now stops at maxTokens = shapeBudget / bytesPerToken: a 16 MiB budget over the whole Derive's allocation per token on the 4 KB line, BenchmarkDerive/bash_long_4k's B/op over its 1,068 tokens, rounded up. That is 395 B and 42,473 tokens. The cost is the whole Derive's because Derive reads a line three times and the leading-cd count and the hook's LeadingDirectory read it again; one reading costs about 106 B per token. TestRES9_BoundMatchesItsArithmetic holds the constant to the formula and the formula to the budgets.json row, so a change that moves Derive's cost per token has to restate the bound with it. Past the bound the lexer returns the first maxTokens tokens with errTokenBound, which Derive reads as uncertain: argc and the masked status are null, the line may write, and the program is named only when its command ended before the bound. A python3 here-document keeps its program; 8 MiB of `echo a a a` does not, because a command still open at the bound could be zsh's `echo a ... () { }`, a function definition. Two allocations went with it, without which the line still allocated 56 MiB: the token slice is sized once from a count of word ends and operators (held to one past the bound) instead of grown, since append's copies cost about four times the slice; and the shell digest is written through a buffer of at most 4 KiB instead of a copy of the whole line. Derive over the 8 MiB line now allocates 23 MiB. On the benchmark lines bash_short drops from 4,624 to 3,280 B/op, bash_long_4k from 430,688 to 421,153 and BenchmarkLexTokens from 128,544 to 112,848; those three rows are restated (best of three, -benchtime 20x, this machine) and budgets_gen.go regenerated. The RES-36 margin mutant is re-anchored on the restated bash_short row, and the H-14 and TB digest mutants on the string form of digest. Tests: TestRES9_BoundMatchesItsArithmetic, TestRES9_LexerStopsAtTheBound (each of the three readings at N, N with trailing blanks, N+1 words and N plus an operator), TestRES9_TokenBoundCapsShapeMemory (8 MiB of short words, TotalAlloc at most 32 MiB), TestRES9_HeredocKeepsItsProgram (an 18,005-token here-document is read whole and names python3; one past the bound still names python3 with argc null). Mutants in RES-9_token-bound.py: the bound lifted to 1 << 30, the lexer reading on past it, the extra token handed back, the cut-off line read as certain, and the slice grown instead of sized. Not done here: the record cannot yet say its written paths and hosts are incomplete past the bound, which RES-9 asks for; those fields are schema 5 (written_paths_complete) and land with P-11. Rubric fix list entry 29 (lld-6), RES-9. --- internal/budget/budgets_gen.go | 2 +- internal/shape/canon_test.go | 2 +- internal/shape/masked.go | 2 +- internal/shape/res9_test.go | 170 +++++++++++++++++++ internal/shape/shape.go | 22 ++- internal/shape/shape_bench_test.go | 4 +- internal/shape/tokenize.go | 76 ++++++++- test/mutation/mutants/H-14_shape.py | 4 +- test/mutation/mutants/RES-36_budgets-file.py | 4 +- test/mutation/mutants/RES-9_token-bound.py | 17 ++ test/mutation/mutants/TB_shape.py | 2 +- test/perf/budgets.json | 4 +- 12 files changed, 288 insertions(+), 21 deletions(-) create mode 100644 internal/shape/res9_test.go create mode 100644 test/mutation/mutants/RES-9_token-bound.py diff --git a/internal/budget/budgets_gen.go b/internal/budget/budgets_gen.go index 1b2e0df0..09fbea70 100644 --- a/internal/budget/budgets_gen.go +++ b/internal/budget/budgets_gen.go @@ -3,4 +3,4 @@ package budget // tableJSON is test/perf/budgets.json, compacted; Table decodes it on first use. -const tableJSON = "{\"budgets_version\":1,\"go_version\":\"go1.27.3\",\"benchmark_rows\":\"the measured best of three on the reference machine (darwin/arm64, go1.27.3, -benchtime 20x, 2026-10-22); a row's max equals its measurement, and the gate's ten percent (scripts/benchgate) is the only margin\",\"benchmark_platform\":\"darwin/arm64\",\"platforms\":{\"darwin/arm64\":{\"reference\":\"Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \\\"-s -w\\\")\",\"set_from\":\"measured 2026-10-13 on the P-06 tree (archive 85.1.2)\",\"tolerance\":0.15,\"tolerance_until\":\"2026-11-06\",\"tolerance_after\":0.1,\"entries\":{},\"case_budgets\":{}},\"linux/amd64\":{\"reference\":\"ubuntu-latest\",\"set_from\":\"measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on d2071b6 by the measure-linux-benchmarks job, run 39762603829 (best of three, -benchtime 20x)\",\"tolerance\":0.1,\"tolerance_until\":\"\",\"tolerance_after\":0.1,\"entries\":{\"hook.bash_short\":{\"p50\":7.71,\"p95\":7.71},\"post.bash_short\":{\"p50\":7.65,\"p95\":7.65},\"hook.edit_50k\":{\"p50\":9.67,\"p95\":9.67},\"post.edit_314k\":{\"p50\":7.83,\"p95\":7.83},\"recap.stop\":{\"p50\":8.07,\"p95\":8.07},\"recap.pending\":{\"p50\":7.93,\"p95\":7.93},\"probe.start\":{\"p50\":7.64,\"p95\":7.64},\"probe.end\":{\"p50\":7.66,\"p95\":7.66}},\"case_budgets\":{\"hook.400B\":7.64,\"hook.40KB\":7.7,\"post.540B\":7.65,\"post.40KB\":7.65},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":4624,\"measured_allocs_per_op\":71,\"bytes_per_op_max\":4624,\"allocs_per_op_max\":71},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":430689,\"measured_allocs_per_op\":2333,\"bytes_per_op_max\":430689,\"allocs_per_op_max\":2333},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":16550,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":16550,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1885613,\"measured_allocs_per_op\":6829,\"bytes_per_op_max\":1885613,\"allocs_per_op_max\":6829},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8417557,\"measured_allocs_per_op\":31710,\"bytes_per_op_max\":8417557,\"allocs_per_op_max\":31710},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36038866,\"measured_allocs_per_op\":122027,\"bytes_per_op_max\":36038866,\"allocs_per_op_max\":122027},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235463295,\"measured_allocs_per_op\":696851,\"bytes_per_op_max\":235463295,\"allocs_per_op_max\":696851},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":50194,\"measured_allocs_per_op\":336,\"bytes_per_op_max\":50194,\"allocs_per_op_max\":336},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":213832,\"measured_allocs_per_op\":282,\"bytes_per_op_max\":213832,\"allocs_per_op_max\":282},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":43419,\"measured_allocs_per_op\":263,\"bytes_per_op_max\":43419,\"allocs_per_op_max\":263},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":885698,\"measured_allocs_per_op\":262,\"bytes_per_op_max\":885698,\"allocs_per_op_max\":262},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":261837,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261837,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1072366,\"measured_allocs_per_op\":3840,\"bytes_per_op_max\":1072366,\"allocs_per_op_max\":3840},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9506120,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9506120,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":260018,\"measured_allocs_per_op\":556,\"bytes_per_op_max\":260018,\"allocs_per_op_max\":556},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9503936,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9503936,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":14273,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14273,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":14778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":432603,\"measured_allocs_per_op\":2948,\"bytes_per_op_max\":432603,\"allocs_per_op_max\":2948},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10061,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10061,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":10407,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":10407,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":440815,\"measured_allocs_per_op\":2946,\"bytes_per_op_max\":440815,\"allocs_per_op_max\":2946},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":440657,\"measured_allocs_per_op\":2945,\"bytes_per_op_max\":440657,\"allocs_per_op_max\":2945},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163584,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163584,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}]}},\"entries\":[{\"id\":\"hook.bash_short\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"bash_short\",\"payload_bytes\":400,\"benchmark\":\"BenchmarkRunHook/bash_short\",\"alloc_bytes_max\":54058,\"maxrss_mib_p50\":5.95,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":10,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths\"},{\"id\":\"hook.edit_50k\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"edit_50k\",\"payload_bytes\":51200,\"benchmark\":\"BenchmarkRunHook/edit_50k\",\"alloc_bytes_max\":218378,\"maxrss_mib_p50\":6.20,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":25,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB\"},{\"id\":\"post.bash_short\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"bash_short\",\"payload_bytes\":540,\"benchmark\":\"BenchmarkRunPost/bash_short\",\"alloc_bytes_max\":46893,\"maxrss_mib_p50\":5.94,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":11,\"wall_ms_p95\":18,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.94 / 5.89 / 6.00 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"post.edit_314k\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"edit_314k\",\"payload_bytes\":314000,\"benchmark\":\"BenchmarkRunPost/edit_314k\",\"alloc_bytes_max\":892802,\"maxrss_mib_p50\":6.73,\"maxrss_mib_p95\":7.16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":12,\"wall_ms_p95\":22,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.73 / 6.67 / 6.73 MiB); p95 is RES-28's line at 314 KB (7.16 MiB), which the archive applies from P-10 in place of 8 / 10 MiB; 60 runs gave p95 6.92, max 6.95\"},{\"id\":\"recap.stop\",\"events\":[\"Stop\",\"StopFailure\"],\"command\":[\"recap\"],\"payload_class\":\"stop_100\",\"payload_bytes\":2048,\"benchmark\":\"BenchmarkRecap/100\",\"alloc_bytes_max\":1075559,\"maxrss_mib_p50\":7.06,\"maxrss_mib_p95\":16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":20,\"wall_ms_p95\":60,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (7.06 / 7.03 / 7.06 MiB); on the 100-call session BenchmarkRecap/100 reads; expected per archive 85.4.1: 12 / 16 MiB, flat in session size\"},{\"id\":\"recap.pending\",\"events\":[\"UserPromptSubmit\"],\"command\":[\"recap\",\"pending\"],\"payload_class\":\"prompt\",\"payload_bytes\":256,\"benchmark\":\"BenchmarkRecapPending/10\",\"alloc_bytes_max\":261190,\"maxrss_mib_p50\":6.06,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":8,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.09 / 6.06 / 5.98 MiB); on the 10-call session BenchmarkRecapPending/10 reads (the earlier 5.2 measured the no-store early return); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"probe.start\",\"events\":[\"SessionStart\"],\"command\":[\"probe\",\"start\"],\"payload_class\":\"session_start\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.97,\"maxrss_mib_p95\":24,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":400,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.02 / 5.97 / 5.91 MiB); nothing queued; expected per archive 85.4.1: 8 MiB with nothing queued at p50, mining and briefing at p95\"},{\"id\":\"probe.end\",\"events\":[\"SessionEnd\"],\"command\":[\"probe\",\"end\"],\"payload_class\":\"session_end\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.98,\"maxrss_mib_p95\":6.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":6,\"wall_ms_p95\":12,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.95 / 6.05 / 5.98 MiB); closing a started session; expected per archive 85.4.1: one record, 6 / 6.5 MiB\"}],\"rss_cases\":[{\"id\":\"hook.400B\",\"entry\":\"hook\",\"payload_bytes\":400,\"budget_mib\":6.5008,\"measured_mib\":5.97,\"gates_from\":\"P-08\"},{\"id\":\"hook.40KB\",\"entry\":\"hook\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.12,\"gates_from\":\"P-08\"},{\"id\":\"hook.1MiB\",\"entry\":\"hook\",\"payload_bytes\":1048576,\"budget_mib\":9.9,\"measured_mib\":9.48,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.4MiB\",\"entry\":\"hook\",\"payload_bytes\":4194304,\"budget_mib\":20.1,\"measured_mib\":19.72,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.8MiB\",\"entry\":\"hook\",\"payload_bytes\":8388608,\"budget_mib\":33.7,\"measured_mib\":31.88,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.540B\",\"entry\":\"post\",\"payload_bytes\":540,\"budget_mib\":6.5011,\"measured_mib\":6.06,\"gates_from\":\"P-08\"},{\"id\":\"post.40KB\",\"entry\":\"post\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.05,\"gates_from\":\"P-08\"},{\"id\":\"post.1MiB\",\"entry\":\"post\",\"payload_bytes\":1048576,\"budget_mib\":8.95,\"measured_mib\":8.86,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.4MiB\",\"entry\":\"post\",\"payload_bytes\":4194304,\"budget_mib\":16.3,\"measured_mib\":15.42,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.8MiB\",\"entry\":\"post\",\"payload_bytes\":8388608,\"budget_mib\":26.1,\"measured_mib\":23.66,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"}],\"per_tool_call\":{\"memory_time_mib_ms_max\":379,\"summed_peak_mib_max\":32,\"process_creations_max\":{\"settings\":6,\"plugin\":6}},\"parallel\":{\"ways\":64,\"summed_peak_mib_max\":704,\"basis\":\"per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured\"},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":4624,\"measured_allocs_per_op\":71,\"bytes_per_op_max\":4624,\"allocs_per_op_max\":71},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":430688,\"measured_allocs_per_op\":2333,\"bytes_per_op_max\":430688,\"allocs_per_op_max\":2333},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":17838,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":17838,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1886832,\"measured_allocs_per_op\":6828,\"bytes_per_op_max\":1886832,\"allocs_per_op_max\":6828},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8422345,\"measured_allocs_per_op\":31708,\"bytes_per_op_max\":8422345,\"allocs_per_op_max\":31708},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36024352,\"measured_allocs_per_op\":121991,\"bytes_per_op_max\":36024352,\"allocs_per_op_max\":121991},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235467679,\"measured_allocs_per_op\":696819,\"bytes_per_op_max\":235467679,\"allocs_per_op_max\":696819},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":54058,\"measured_allocs_per_op\":362,\"bytes_per_op_max\":54058,\"allocs_per_op_max\":362},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":218378,\"measured_allocs_per_op\":310,\"bytes_per_op_max\":218378,\"allocs_per_op_max\":310},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":46893,\"measured_allocs_per_op\":290,\"bytes_per_op_max\":46893,\"allocs_per_op_max\":290},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":892802,\"measured_allocs_per_op\":295,\"bytes_per_op_max\":892802,\"allocs_per_op_max\":295},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":263952,\"measured_allocs_per_op\":558,\"bytes_per_op_max\":263952,\"allocs_per_op_max\":558},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1075559,\"measured_allocs_per_op\":3849,\"bytes_per_op_max\":1075559,\"allocs_per_op_max\":3849},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9513616,\"measured_allocs_per_op\":35359,\"bytes_per_op_max\":9513616,\"allocs_per_op_max\":35359},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":261190,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261190,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9511308,\"measured_allocs_per_op\":35356,\"bytes_per_op_max\":9511308,\"allocs_per_op_max\":35356},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":15778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":15778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":16426,\"measured_allocs_per_op\":83,\"bytes_per_op_max\":16426,\"allocs_per_op_max\":83},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":483310,\"measured_allocs_per_op\":3142,\"bytes_per_op_max\":483310,\"allocs_per_op_max\":3142},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10646,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10646,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":11351,\"measured_allocs_per_op\":51,\"bytes_per_op_max\":11351,\"allocs_per_op_max\":51},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":478744,\"measured_allocs_per_op\":2949,\"bytes_per_op_max\":478744,\"allocs_per_op_max\":2949},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":499297,\"measured_allocs_per_op\":3138,\"bytes_per_op_max\":499297,\"allocs_per_op_max\":3138},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163589,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163589,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459141,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459141,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}],\"growth\":[],\"disk\":{\"bytes_per_call_max\":6000}}" +const tableJSON = "{\"budgets_version\":1,\"go_version\":\"go1.27.3\",\"benchmark_rows\":\"the measured best of three on the reference machine (darwin/arm64, go1.27.3, -benchtime 20x, 2026-10-22); a row's max equals its measurement, and the gate's ten percent (scripts/benchgate) is the only margin\",\"benchmark_platform\":\"darwin/arm64\",\"platforms\":{\"darwin/arm64\":{\"reference\":\"Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \\\"-s -w\\\")\",\"set_from\":\"measured 2026-10-13 on the P-06 tree (archive 85.1.2)\",\"tolerance\":0.15,\"tolerance_until\":\"2026-11-06\",\"tolerance_after\":0.1,\"entries\":{},\"case_budgets\":{}},\"linux/amd64\":{\"reference\":\"ubuntu-latest\",\"set_from\":\"measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on d2071b6 by the measure-linux-benchmarks job, run 39762603829 (best of three, -benchtime 20x)\",\"tolerance\":0.1,\"tolerance_until\":\"\",\"tolerance_after\":0.1,\"entries\":{\"hook.bash_short\":{\"p50\":7.71,\"p95\":7.71},\"post.bash_short\":{\"p50\":7.65,\"p95\":7.65},\"hook.edit_50k\":{\"p50\":9.67,\"p95\":9.67},\"post.edit_314k\":{\"p50\":7.83,\"p95\":7.83},\"recap.stop\":{\"p50\":8.07,\"p95\":8.07},\"recap.pending\":{\"p50\":7.93,\"p95\":7.93},\"probe.start\":{\"p50\":7.64,\"p95\":7.64},\"probe.end\":{\"p50\":7.66,\"p95\":7.66}},\"case_budgets\":{\"hook.400B\":7.64,\"hook.40KB\":7.7,\"post.540B\":7.65,\"post.40KB\":7.65},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":4624,\"measured_allocs_per_op\":71,\"bytes_per_op_max\":4624,\"allocs_per_op_max\":71},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":430689,\"measured_allocs_per_op\":2333,\"bytes_per_op_max\":430689,\"allocs_per_op_max\":2333},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":16550,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":16550,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1885613,\"measured_allocs_per_op\":6829,\"bytes_per_op_max\":1885613,\"allocs_per_op_max\":6829},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8417557,\"measured_allocs_per_op\":31710,\"bytes_per_op_max\":8417557,\"allocs_per_op_max\":31710},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36038866,\"measured_allocs_per_op\":122027,\"bytes_per_op_max\":36038866,\"allocs_per_op_max\":122027},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235463295,\"measured_allocs_per_op\":696851,\"bytes_per_op_max\":235463295,\"allocs_per_op_max\":696851},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":50194,\"measured_allocs_per_op\":336,\"bytes_per_op_max\":50194,\"allocs_per_op_max\":336},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":213832,\"measured_allocs_per_op\":282,\"bytes_per_op_max\":213832,\"allocs_per_op_max\":282},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":43419,\"measured_allocs_per_op\":263,\"bytes_per_op_max\":43419,\"allocs_per_op_max\":263},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":885698,\"measured_allocs_per_op\":262,\"bytes_per_op_max\":885698,\"allocs_per_op_max\":262},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":261837,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261837,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1072366,\"measured_allocs_per_op\":3840,\"bytes_per_op_max\":1072366,\"allocs_per_op_max\":3840},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9506120,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9506120,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":260018,\"measured_allocs_per_op\":556,\"bytes_per_op_max\":260018,\"allocs_per_op_max\":556},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9503936,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9503936,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":14273,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14273,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":14778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":432603,\"measured_allocs_per_op\":2948,\"bytes_per_op_max\":432603,\"allocs_per_op_max\":2948},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10061,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10061,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":10407,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":10407,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":440815,\"measured_allocs_per_op\":2946,\"bytes_per_op_max\":440815,\"allocs_per_op_max\":2946},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":440657,\"measured_allocs_per_op\":2945,\"bytes_per_op_max\":440657,\"allocs_per_op_max\":2945},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163584,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163584,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}]}},\"entries\":[{\"id\":\"hook.bash_short\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"bash_short\",\"payload_bytes\":400,\"benchmark\":\"BenchmarkRunHook/bash_short\",\"alloc_bytes_max\":54058,\"maxrss_mib_p50\":5.95,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":10,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths\"},{\"id\":\"hook.edit_50k\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"edit_50k\",\"payload_bytes\":51200,\"benchmark\":\"BenchmarkRunHook/edit_50k\",\"alloc_bytes_max\":218378,\"maxrss_mib_p50\":6.20,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":25,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB\"},{\"id\":\"post.bash_short\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"bash_short\",\"payload_bytes\":540,\"benchmark\":\"BenchmarkRunPost/bash_short\",\"alloc_bytes_max\":46893,\"maxrss_mib_p50\":5.94,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":11,\"wall_ms_p95\":18,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.94 / 5.89 / 6.00 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"post.edit_314k\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"edit_314k\",\"payload_bytes\":314000,\"benchmark\":\"BenchmarkRunPost/edit_314k\",\"alloc_bytes_max\":892802,\"maxrss_mib_p50\":6.73,\"maxrss_mib_p95\":7.16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":12,\"wall_ms_p95\":22,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.73 / 6.67 / 6.73 MiB); p95 is RES-28's line at 314 KB (7.16 MiB), which the archive applies from P-10 in place of 8 / 10 MiB; 60 runs gave p95 6.92, max 6.95\"},{\"id\":\"recap.stop\",\"events\":[\"Stop\",\"StopFailure\"],\"command\":[\"recap\"],\"payload_class\":\"stop_100\",\"payload_bytes\":2048,\"benchmark\":\"BenchmarkRecap/100\",\"alloc_bytes_max\":1075559,\"maxrss_mib_p50\":7.06,\"maxrss_mib_p95\":16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":20,\"wall_ms_p95\":60,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (7.06 / 7.03 / 7.06 MiB); on the 100-call session BenchmarkRecap/100 reads; expected per archive 85.4.1: 12 / 16 MiB, flat in session size\"},{\"id\":\"recap.pending\",\"events\":[\"UserPromptSubmit\"],\"command\":[\"recap\",\"pending\"],\"payload_class\":\"prompt\",\"payload_bytes\":256,\"benchmark\":\"BenchmarkRecapPending/10\",\"alloc_bytes_max\":261190,\"maxrss_mib_p50\":6.06,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":8,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.09 / 6.06 / 5.98 MiB); on the 10-call session BenchmarkRecapPending/10 reads (the earlier 5.2 measured the no-store early return); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"probe.start\",\"events\":[\"SessionStart\"],\"command\":[\"probe\",\"start\"],\"payload_class\":\"session_start\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.97,\"maxrss_mib_p95\":24,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":400,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.02 / 5.97 / 5.91 MiB); nothing queued; expected per archive 85.4.1: 8 MiB with nothing queued at p50, mining and briefing at p95\"},{\"id\":\"probe.end\",\"events\":[\"SessionEnd\"],\"command\":[\"probe\",\"end\"],\"payload_class\":\"session_end\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.98,\"maxrss_mib_p95\":6.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":6,\"wall_ms_p95\":12,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.95 / 6.05 / 5.98 MiB); closing a started session; expected per archive 85.4.1: one record, 6 / 6.5 MiB\"}],\"rss_cases\":[{\"id\":\"hook.400B\",\"entry\":\"hook\",\"payload_bytes\":400,\"budget_mib\":6.5008,\"measured_mib\":5.97,\"gates_from\":\"P-08\"},{\"id\":\"hook.40KB\",\"entry\":\"hook\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.12,\"gates_from\":\"P-08\"},{\"id\":\"hook.1MiB\",\"entry\":\"hook\",\"payload_bytes\":1048576,\"budget_mib\":9.9,\"measured_mib\":9.48,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.4MiB\",\"entry\":\"hook\",\"payload_bytes\":4194304,\"budget_mib\":20.1,\"measured_mib\":19.72,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.8MiB\",\"entry\":\"hook\",\"payload_bytes\":8388608,\"budget_mib\":33.7,\"measured_mib\":31.88,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.540B\",\"entry\":\"post\",\"payload_bytes\":540,\"budget_mib\":6.5011,\"measured_mib\":6.06,\"gates_from\":\"P-08\"},{\"id\":\"post.40KB\",\"entry\":\"post\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.05,\"gates_from\":\"P-08\"},{\"id\":\"post.1MiB\",\"entry\":\"post\",\"payload_bytes\":1048576,\"budget_mib\":8.95,\"measured_mib\":8.86,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.4MiB\",\"entry\":\"post\",\"payload_bytes\":4194304,\"budget_mib\":16.3,\"measured_mib\":15.42,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.8MiB\",\"entry\":\"post\",\"payload_bytes\":8388608,\"budget_mib\":26.1,\"measured_mib\":23.66,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"}],\"per_tool_call\":{\"memory_time_mib_ms_max\":379,\"summed_peak_mib_max\":32,\"process_creations_max\":{\"settings\":6,\"plugin\":6}},\"parallel\":{\"ways\":64,\"summed_peak_mib_max\":704,\"basis\":\"per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured\"},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":3280,\"measured_allocs_per_op\":59,\"bytes_per_op_max\":3280,\"allocs_per_op_max\":59},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":421153,\"measured_allocs_per_op\":2301,\"bytes_per_op_max\":421153,\"allocs_per_op_max\":2301},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":17838,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":17838,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1886832,\"measured_allocs_per_op\":6828,\"bytes_per_op_max\":1886832,\"allocs_per_op_max\":6828},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8422345,\"measured_allocs_per_op\":31708,\"bytes_per_op_max\":8422345,\"allocs_per_op_max\":31708},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36024352,\"measured_allocs_per_op\":121991,\"bytes_per_op_max\":36024352,\"allocs_per_op_max\":121991},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235467679,\"measured_allocs_per_op\":696819,\"bytes_per_op_max\":235467679,\"allocs_per_op_max\":696819},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":54058,\"measured_allocs_per_op\":362,\"bytes_per_op_max\":54058,\"allocs_per_op_max\":362},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":218378,\"measured_allocs_per_op\":310,\"bytes_per_op_max\":218378,\"allocs_per_op_max\":310},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":46893,\"measured_allocs_per_op\":290,\"bytes_per_op_max\":46893,\"allocs_per_op_max\":290},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":892802,\"measured_allocs_per_op\":295,\"bytes_per_op_max\":892802,\"allocs_per_op_max\":295},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":263952,\"measured_allocs_per_op\":558,\"bytes_per_op_max\":263952,\"allocs_per_op_max\":558},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1075559,\"measured_allocs_per_op\":3849,\"bytes_per_op_max\":1075559,\"allocs_per_op_max\":3849},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9513616,\"measured_allocs_per_op\":35359,\"bytes_per_op_max\":9513616,\"allocs_per_op_max\":35359},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":261190,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261190,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9511308,\"measured_allocs_per_op\":35356,\"bytes_per_op_max\":9511308,\"allocs_per_op_max\":35356},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":15778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":15778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":16426,\"measured_allocs_per_op\":83,\"bytes_per_op_max\":16426,\"allocs_per_op_max\":83},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":483310,\"measured_allocs_per_op\":3142,\"bytes_per_op_max\":483310,\"allocs_per_op_max\":3142},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10646,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10646,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":11351,\"measured_allocs_per_op\":51,\"bytes_per_op_max\":11351,\"allocs_per_op_max\":51},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":478744,\"measured_allocs_per_op\":2949,\"bytes_per_op_max\":478744,\"allocs_per_op_max\":2949},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":499297,\"measured_allocs_per_op\":3138,\"bytes_per_op_max\":499297,\"allocs_per_op_max\":3138},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163589,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163589,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459141,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459141,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}],\"growth\":[],\"disk\":{\"bytes_per_call_max\":6000}}" diff --git a/internal/shape/canon_test.go b/internal/shape/canon_test.go index 5f0ec00d..a893cd61 100644 --- a/internal/shape/canon_test.go +++ b/internal/shape/canon_test.go @@ -76,7 +76,7 @@ func TestCanonicalDigestIsUnchanged(t *testing.T) { if got, want := canonicalDigest(key, "Edit", in), hex.EncodeToString(m.Sum(nil)); got != want { t.Fatalf("digest moved: %s, was %s", got, want) } - if got, want := canonicalDigest(key, "Edit", json.RawMessage(`{bad`)), digest(key, "Edit", []byte(`{bad`)); got != want { + if got, want := canonicalDigest(key, "Edit", json.RawMessage(`{bad`)), digest(key, "Edit", `{bad`); got != want { t.Fatalf("invalid input digest %s, want %s", got, want) } } diff --git a/internal/shape/masked.go b/internal/shape/masked.go index fcc1b3e9..b9f178ab 100644 --- a/internal/shape/masked.go +++ b/internal/shape/masked.go @@ -892,7 +892,7 @@ func (p *lineParser) runnerDigest(g int, key []byte) *string { if p.runners[g].displaced { return nil } - d := digest(key, "\x00runner", []byte(p.runners[g].key)) + d := digest(key, "\x00runner", p.runners[g].key) return &d } diff --git a/internal/shape/res9_test.go b/internal/shape/res9_test.go new file mode 100644 index 00000000..6c324d2f --- /dev/null +++ b/internal/shape/res9_test.go @@ -0,0 +1,170 @@ +package shape + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" +) + +// TestRES9_BoundMatchesItsArithmetic pins maxTokens to the formula it is +// derived from and the formula to the measurement it is derived from: +// shapeBudget over the whole Derive's allocation per token on the 4 KB line, +// which is BenchmarkDerive/bash_long_4k's recorded B/op over that line's +// benchLongTokens, rounded up. A budgets.json row restated after a change +// that moves Derive's cost per token fails here until the bound is restated +// with it. +func TestRES9_BoundMatchesItsArithmetic(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "test", "perf", "budgets.json")) + if err != nil { + t.Fatal(err) + } + var b struct { + Benchmarks []struct { + Name string `json:"name"` + Measured int64 `json:"measured_bytes_per_op"` + } `json:"benchmarks"` + } + if err := json.Unmarshal(raw, &b); err != nil { + t.Fatal(err) + } + var perOp int64 + for _, r := range b.Benchmarks { + if r.Name == "BenchmarkDerive/bash_long_4k" { + perOp = r.Measured + } + } + if perOp <= 0 { + t.Fatal("budgets.json has no measured row for BenchmarkDerive/bash_long_4k") + } + perToken := (perOp + benchLongTokens - 1) / benchLongTokens + if bytesPerToken != perToken { + t.Errorf("bytesPerToken = %d, but the recorded Derive is %d B over %d tokens: %d per token", bytesPerToken, perOp, benchLongTokens, perToken) + } + if shapeBudget != 16<<20 { + t.Errorf("shapeBudget = %d, want 16 MiB", shapeBudget) + } + if maxTokens != shapeBudget/bytesPerToken { + t.Errorf("maxTokens = %d, want shapeBudget/bytesPerToken = %d", maxTokens, shapeBudget/bytesPerToken) + } +} + +// words is a line of n one-letter words after echo: n+1 tokens. +func words(n int) string { + return "echo" + strings.Repeat(" a", n) +} + +// boundBuildable stops a test that builds a line of maxTokens words when the +// bound is far past what it was derived as: a mutant that lifts it to 1 << 30 +// must fail here, not build a 2 GiB line. +func boundBuildable(t *testing.T) { + t.Helper() + if maxTokens > 1<<20 { + t.Fatalf("maxTokens is %d, past any line this test will build", maxTokens) + } +} + +// TestRES9_LexerStopsAtTheBound: each of the three readings of a line lexes +// exactly maxTokens tokens without complaint, and at one more stops with +// errTokenBound and the maxTokens tokens it completed, so a caller can still +// name a program that came before the bound. +func TestRES9_LexerStopsAtTheBound(t *testing.T) { + boundBuildable(t) + for _, tc := range []struct { + name string + lex func(string) ([]token, error) + }{ + {"tokenizeShape", tokenizeShape}, + {"tokenizeProgram", tokenizeProgram}, + {"tokenizeList", tokenizeList}, + } { + toks, err := tc.lex(words(maxTokens - 1)) + if err != nil || len(toks) != maxTokens { + t.Errorf("%s at the bound: %d tokens, %v; want %d and no error", tc.name, len(toks), err, maxTokens) + } + toks, err = tc.lex(words(maxTokens-1) + " ") + if err != nil || len(toks) != maxTokens { + t.Errorf("%s at the bound with trailing blanks: %d tokens, %v; want %d and no error", tc.name, len(toks), err, maxTokens) + } + toks, err = tc.lex(words(maxTokens)) + if !errors.Is(err, errTokenBound) || len(toks) != maxTokens { + t.Errorf("%s one past the bound: %d tokens, %v; want %d and errTokenBound", tc.name, len(toks), err, maxTokens) + } + toks, err = tc.lex(words(maxTokens-1) + ";") + if !errors.Is(err, errTokenBound) || len(toks) != maxTokens { + t.Errorf("%s one operator past the bound: %d tokens, %v; want %d and errTokenBound", tc.name, len(toks), err, maxTokens) + } + } +} + +// TestRES9_TokenBoundCapsShapeMemory: 8 MiB of `echo a a a ...` is four +// million words, and lexed whole the shape of that line allocated 4.5 GB and +// drove one hook to about 1.07 GB of RSS. At the bound it allocates at most +// 32 MiB and says what it could not read: argc and the masked status are +// null and the line may write. So is the program: no separator comes before +// the bound, so the command is still open there, and `echo a a ... a () { +// ...; }` would define functions named echo and a in zsh rather than run +// anything (definesFunctions). The heredoc test below is the line whose +// program the bound keeps. +func TestRES9_TokenBoundCapsShapeMemory(t *testing.T) { + cmd := words(4 << 20) + if len(cmd) < 8<<20 { + t.Fatalf("the line is %d bytes, want 8 MiB", len(cmd)) + } + in := bashInput(t, cmd) + var m0, m1 runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&m0) + got := Derive("Bash", in, benchKey) + runtime.ReadMemStats(&m1) + if alloc := m1.TotalAlloc - m0.TotalAlloc; alloc > 32<<20 { + t.Errorf("Derive over 8 MiB of short words allocated %d MiB, want at most 32", alloc>>20) + } + if got.Program != nil || got.Argc != nil || got.StatusMasked != nil || !got.MayWrite { + t.Errorf("program %v, argc %v, status_masked %v, may_write %v; want null, null, null, true past the bound", + show(got.Program), show(got.Argc), show(got.StatusMasked), got.MayWrite) + } +} + +// heredocLine is `python3 - <<'E'`, a body of n words over lines of ten, and +// the delimiter line. +func heredocLine(n int) string { + var b strings.Builder + b.WriteString("python3 - <<'E'\n") + for i := 0; i < n; i++ { + b.WriteString("w" + strconv.Itoa(i%10)) + if i%10 == 9 { + b.WriteByte('\n') + } else { + b.WriteByte(' ') + } + } + b.WriteString("\nE") + return b.String() +} + +// TestRES9_HeredocKeepsItsProgram: a script handed to python3 as a +// here-document is read word by word by the program search, which does not +// skip bodies. An 18,005-token one is well under the bound and is read whole; +// one past the bound is not, and still names python3 -- the newline after its +// operator ends the command before the bound -- with argc null and the line +// marked as one that may write. +func TestRES9_HeredocKeepsItsProgram(t *testing.T) { + boundBuildable(t) + under := heredocLine(18_000) + if toks, err := tokenizeShape(under); err != nil || len(toks) != 18_005 { + t.Fatalf("the heredoc line lexes to %d tokens, %v; want 18,005", len(toks), err) + } + got := Derive("Bash", bashInput(t, under), benchKey) + if got.Program == nil || *got.Program != "python3" || got.Argc == nil || *got.Argc != 18_005 { + t.Errorf("18,005 tokens: program %v, argc %v; want python3 and 18005", show(got.Program), show(got.Argc)) + } + got = Derive("Bash", bashInput(t, heredocLine(maxTokens)), benchKey) + if got.Program == nil || *got.Program != "python3" || got.Argc != nil || !got.MayWrite { + t.Errorf("past the bound: program %v, argc %v, may_write %v; want python3, null, true", show(got.Program), show(got.Argc), got.MayWrite) + } +} diff --git a/internal/shape/shape.go b/internal/shape/shape.go index fe726fc0..3a455a54 100644 --- a/internal/shape/shape.go +++ b/internal/shape/shape.go @@ -132,7 +132,7 @@ func Derive(toolName string, toolInput json.RawMessage, key []byte) Shape { // A shell tool digests its command line and nothing else. Claude Code // attaches a free-text description to a Bash call, so a digest over the // whole input would move whenever the wording did and would group nothing. - s.Digest = digest(key, toolName, []byte(cmd)) + s.Digest = digest(key, toolName, cmd) shaped, err := tokenizeShape(cmd) toks := make([]string, len(shaped)) @@ -148,7 +148,9 @@ func Derive(toolName string, toolInput json.RawMessage, key []byte) Shape { // tokens above, as it always was. pshaped, perr := tokenizeProgram(cmd) - uncertain := perr == errUncertain + // A line read only to the token bound is as uncertain as one the lexer + // stopped in: what follows is unknown, not absent. + uncertain := perr == errUncertain || perr == errTokenBound // A line whose program cannot be named, or which the lexer could not // read to its end, may write: nobody saw what it runs. s.MayWrite = true @@ -220,11 +222,21 @@ func commandField(raw json.RawMessage) (string, bool) { // is fixed at 64 characters regardless of how large the body was, which is what // lets a record's serialized length be independent of the size of what it // describes. -func digest(key []byte, toolName string, body []byte) string { +// +// body is written through a buffer of at most 4 KiB rather than converted +// whole: []byte(body) would copy the whole command line, 8 MiB at the payload +// cap, to hash it once (RES-9's memory test counts that copy). A body that +// fits is copied once, as the conversion did. +func digest(key []byte, toolName, body string) string { m := hmac.New(sha256.New, key) m.Write([]byte(toolName)) m.Write([]byte{0}) - m.Write(body) + buf := make([]byte, min(len(body), 4<<10)) + for len(body) > 0 { + n := copy(buf, body) + m.Write(buf[:n]) + body = body[n:] + } return hex.EncodeToString(m.Sum(nil)) } @@ -241,7 +253,7 @@ func CWDDigest(key []byte, cwd string) string { if cwd == "" { return "" } - return digest(key, "\x00cwd", []byte(cwd)) + return digest(key, "\x00cwd", cwd) } // canonical re-encodes JSON so that two inputs differing only in key order or diff --git a/internal/shape/shape_bench_test.go b/internal/shape/shape_bench_test.go index bdc15a02..d6cd6ec9 100644 --- a/internal/shape/shape_bench_test.go +++ b/internal/shape/shape_bench_test.go @@ -117,13 +117,13 @@ func benchDeriveInput(tb testing.TB, name string) (json.RawMessage, string) { tb.Helper() switch name { case "bash_short": - return benchBashInput(tb, benchShortCommand), digest(benchKey, "Bash", []byte(benchShortCommand)) + return benchBashInput(tb, benchShortCommand), digest(benchKey, "Bash", benchShortCommand) case "bash_long_4k": long := benchLongCommand() if len(long) != benchLongBytes { tb.Fatalf("bash_long_4k command is %d bytes, want %d", len(long), benchLongBytes) } - return benchBashInput(tb, long), digest(benchKey, "Bash", []byte(long)) + return benchBashInput(tb, long), digest(benchKey, "Bash", long) case "edit_50k": edit := benchEditInput(tb) return edit, canonicalDigest(benchKey, "Edit", edit) diff --git a/internal/shape/tokenize.go b/internal/shape/tokenize.go index 530d41b5..0fff75b4 100644 --- a/internal/shape/tokenize.go +++ b/internal/shape/tokenize.go @@ -18,6 +18,44 @@ var errUnterminated = errors.New("unterminated quote") // unknown rather than absent, and the tokens are those completed before it. var errUncertain = errors.New("uncertain reading") +// errTokenBound is lex's stop at maxTokens: the line goes on past what this +// package will read, and what follows is unknown rather than absent. The +// tokens are the first maxTokens, so a program named before the bound is +// still named. +var errTokenBound = errors.New("token bound") + +// The token bound (RES-9). A hook payload is read up to 8 MiB, which bounds +// the bytes and not the work: 8 MiB of one-letter words is four million +// tokens, and lexed whole the shape of that one line allocated 4.5 GB and +// drove a hook to about 1.07 GB of RSS. Lexing stops at maxTokens, derived +// from a memory budget for the shape of one call, shapeBudget, over the cost +// of one token. That cost is the whole Derive's, not the lexer's: Derive reads +// a line three times (tokenizeShape for argc, tokenizeProgram for the +// program, tokenizeList for the masked status), its leading-cd count a fourth +// and the hook's LeadingDirectory a fifth, so a token costs about 395 bytes +// where one reading of it costs about 106 (BenchmarkLexTokens). +// bytesPerToken is BenchmarkDerive/bash_long_4k's 421,153 B/op over that +// line's 1,068 tokens (test/perf/budgets.json, measured 2026-10-13 under +// go1.27.2), 394.3, rounded up; TestRES9_BoundMatchesItsArithmetic holds the +// constant to that row, so a change that moves Derive's cost per token +// restates the bound with the row. The bound is 42,473 tokens: a script +// handed to an interpreter as a here-document, which the program search +// reads word by word, runs to a few thousand. +const ( + shapeBudget = 16 << 20 + bytesPerToken = 395 + maxTokens = shapeBudget / bytesPerToken +) + +// bounded is a lex result held to maxTokens: see errTokenBound. lex stops +// at most one token past the bound, and this takes that one back. +func bounded(toks []token, err error) ([]token, error) { + if len(toks) > maxTokens { + return toks[:maxTokens], errTokenBound + } + return toks, err +} + // tokenize splits a command line the way a POSIX shell would split it, as far // as quoting and metacharacters go. It does not expand anything: no variables, // no globs, no command substitution. Expansion would mean evaluating the input, @@ -112,7 +150,7 @@ func tokenizeMarked(s string) ([]string, []bool, error) { // tokenizeShape splits a command line as tokenize does and reports, per // token, what the text cannot: see token. func tokenizeShape(s string) ([]token, error) { - return lex(s, false, false, nil) + return bounded(lex(s, false, false, nil)) } // tokenizeProgram is tokenizeShape for the program search, which reads one @@ -136,7 +174,7 @@ func tokenizeShape(s string) ([]token, error) { // rather than guess: see lex. func tokenizeProgram(s string) ([]token, error) { j, joins := joinContinuations(s) - return lex(j, true, false, joins) + return bounded(lex(j, true, false, joins)) } // tokenizeList is tokenizeProgram for the walk over a line's whole list of @@ -159,7 +197,7 @@ func tokenizeProgram(s string) ([]token, error) { // instead: what it names is unchanged. func tokenizeList(s string) ([]token, error) { j, joins := joinLines(s, true) - return lex(j, true, true, joins) + return bounded(lex(j, true, true, joins)) } // lex is tokenizeShape and tokenizeProgram; program selects the second's @@ -178,7 +216,7 @@ func tokenizeList(s string) ([]token, error) { // docs selects tokenizeList's reading of here-documents and comments. func lex(s string, program, docs bool, joins []int) ([]token, error) { var ( - toks []token + toks = make([]token, 0, tokenEstimate(s)) w = newWord() sawNL bool // gap: a blank or newline since the last token ended. @@ -245,6 +283,10 @@ func lex(s string, program, docs bool, joins []int) ([]token, error) { } for i := 0; i < len(s); i++ { + if len(toks) > maxTokens { + // Past the token bound: see bounded. + return toks, errTokenBound + } c := s[i] switch { @@ -417,6 +459,32 @@ func lex(s string, program, docs bool, joins []int) ([]token, error) { return toks, nil } +// tokenEstimate is how many tokens lex will emit from s, for the common line +// an upper bound -- one per word, counted at the blank or metacharacter after +// it, one per metacharacter, and one more -- held to one past maxTokens, so +// the token slice is allocated once rather than grown. Grown, append's copies +// of a slice this size cost about four times the slice it leaves, and at the +// token bound that was most of what one reading of a line allocated. Where it +// is short, as on a line of quoted blanks, lex grows the slice as before. +func tokenEstimate(s string) int { + n, inWord := 1, false + for i := 0; i < len(s) && n <= maxTokens; i++ { + switch c := s[i]; { + case c == ' ' || c == '\t' || c == '\n' || c == '\r': + if inWord { + n++ + } + inWord = false + case isMeta(c): // a token of its own, and the end of a word + n++ + inWord = false + default: + inWord = true + } + } + return min(n, maxTokens+1) +} + // ansiCEnd returns the index of the quote that closes a $'...' string whose // body s begins -- the first one no backslash escapes -- or -1 if none does. func ansiCEnd(s string) int { diff --git a/test/mutation/mutants/H-14_shape.py b/test/mutation/mutants/H-14_shape.py index d7baf6be..9deaa910 100644 --- a/test/mutation/mutants/H-14_shape.py +++ b/test/mutation/mutants/H-14_shape.py @@ -9,8 +9,8 @@ "\tif err == nil {\n\t\tn := len(dropLeadingAssignments(toks))\n\t\ts.Argc = &n\n\t}", "\tn := len(dropLeadingAssignments(toks))\n\tif err != nil {\n\t\tn = 0\n\t}\n\ts.Argc = &n", "TestH14_Untokenizable") m("H-14 shell digest covers the whole tool_input again", "internal/shape/shape.go", - "\ts.Digest = digest(key, toolName, []byte(cmd))", "\ts.Digest = canonicalDigest(key, toolName, toolInput)", + "\ts.Digest = digest(key, toolName, cmd)", "\ts.Digest = canonicalDigest(key, toolName, toolInput)", "TestH14_ShellDigestCoversTheCommandAlone") m("H-14 non-shell digest covers only the tool name", "internal/shape/shape.go", - "\t\ts.Digest = canonicalDigest(key, toolName, toolInput)", "\t\ts.Digest = digest(key, toolName, nil)", + "\t\ts.Digest = canonicalDigest(key, toolName, toolInput)", "\t\ts.Digest = digest(key, toolName, \"\")", "TestH14_NonShellDigestCoversTheWholeInput") diff --git a/test/mutation/mutants/RES-36_budgets-file.py b/test/mutation/mutants/RES-36_budgets-file.py index 9f828e20..68b0e61f 100644 --- a/test/mutation/mutants/RES-36_budgets-file.py +++ b/test/mutation/mutants/RES-36_budgets-file.py @@ -15,5 +15,5 @@ # The reference row, on its own indented line: linux/amd64's inline row for the # same benchmark can carry the very same figures. m("RES-36 a benchmark row hides a margin above its measurement", "test/perf/budgets.json", - ' {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 4624, "measured_allocs_per_op": 71, "bytes_per_op_max": 4624, "allocs_per_op_max": 71}', - ' {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 4624, "measured_allocs_per_op": 71, "bytes_per_op_max": 5086, "allocs_per_op_max": 78}', "TestRES36d_") + ' {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 3280, "measured_allocs_per_op": 59, "bytes_per_op_max": 3280, "allocs_per_op_max": 59}', + ' {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 3280, "measured_allocs_per_op": 59, "bytes_per_op_max": 3608, "allocs_per_op_max": 64}', "TestRES36d_") diff --git a/test/mutation/mutants/RES-9_token-bound.py b/test/mutation/mutants/RES-9_token-bound.py new file mode 100644 index 00000000..94bd8f5e --- /dev/null +++ b/test/mutation/mutants/RES-9_token-bound.py @@ -0,0 +1,17 @@ +# RES-9: lexing stops at maxTokens, derived from the whole Derive's +# allocation per token, and the shape of a line read only to the bound says +# what it could not read. Lifting the bound, lexing on past it, handing back +# the token past it, reading the cut-off line as certain, or growing the +# token slice instead of sizing it is each the regression. +m("RES-9 the token bound is lifted", "internal/shape/tokenize.go", + "\tmaxTokens = shapeBudget / bytesPerToken\n", "\tmaxTokens = 1 << 30\n", "TestRES9_") +m("RES-9 the lexer reads on past the bound", "internal/shape/tokenize.go", + "\t\tif len(toks) > maxTokens {\n\t\t\t// Past the token bound: see bounded.\n", + "\t\tif false {\n\t\t\t// Past the token bound: see bounded.\n", "TestRES9_TokenBoundCapsShapeMemory") +m("RES-9 the token past the bound is handed back", "internal/shape/tokenize.go", + "\t\treturn toks[:maxTokens], errTokenBound\n", "\t\treturn toks, errTokenBound\n", "TestRES9_LexerStopsAtTheBound") +m("RES-9 a line read to the bound is read as certain", "internal/shape/shape.go", + "\tuncertain := perr == errUncertain || perr == errTokenBound\n", "\tuncertain := perr == errUncertain\n", + "TestRES9_TokenBoundCapsShapeMemory") +m("RES-9 the token slice is grown, not sized", "internal/shape/tokenize.go", + "\t\ttoks = make([]token, 0, tokenEstimate(s))\n", "\t\ttoks = []token(nil)\n", "TestRES9_TokenBoundCapsShapeMemory") diff --git a/test/mutation/mutants/TB_shape.py b/test/mutation/mutants/TB_shape.py index de01c3f7..877713fa 100644 --- a/test/mutation/mutants/TB_shape.py +++ b/test/mutation/mutants/TB_shape.py @@ -462,7 +462,7 @@ "", "TestTestBending_ARepeatedRelativeCd|TestSchema3_|TestH13_") m("TB the cwd digest is the plain path", "internal/shape/shape.go", - "\treturn digest(key, \"\\x00cwd\", []byte(cwd))\n", + "\treturn digest(key, \"\\x00cwd\", cwd)\n", "\treturn cwd\n", "TestTestBending_ARepeatedRelativeCd") diff --git a/test/perf/budgets.json b/test/perf/budgets.json index 8ac60fc0..e6f23e78 100644 --- a/test/perf/budgets.json +++ b/test/perf/budgets.json @@ -29,8 +29,8 @@ "per_tool_call": {"memory_time_mib_ms_max": 379, "summed_peak_mib_max": 32, "process_creations_max": {"settings": 6, "plugin": 6}}, "parallel": {"ways": 64, "summed_peak_mib_max": 704, "basis": "per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured"}, "benchmarks": [ - {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 4624, "measured_allocs_per_op": 71, "bytes_per_op_max": 4624, "allocs_per_op_max": 71}, - {"name": "BenchmarkDerive/bash_long_4k", "measured_bytes_per_op": 430688, "measured_allocs_per_op": 2333, "bytes_per_op_max": 430688, "allocs_per_op_max": 2333}, + {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 3280, "measured_allocs_per_op": 59, "bytes_per_op_max": 3280, "allocs_per_op_max": 59}, + {"name": "BenchmarkDerive/bash_long_4k", "measured_bytes_per_op": 421153, "measured_allocs_per_op": 2301, "bytes_per_op_max": 421153, "allocs_per_op_max": 2301}, {"name": "BenchmarkDerive/edit_50k", "measured_bytes_per_op": 63464, "measured_allocs_per_op": 28, "bytes_per_op_max": 63464, "allocs_per_op_max": 28}, {"name": "BenchmarkLexTokens", "measured_bytes_per_op": 128544, "measured_allocs_per_op": 750, "bytes_per_op_max": 128544, "allocs_per_op_max": 750}, {"name": "BenchmarkRecordBytesPerCall", "measured_bytes_per_op": 17838, "measured_allocs_per_op": 95, "bytes_per_op_max": 17838, "allocs_per_op_max": 95}, From ad36eaa7ae68459517675ab66fd9b898240c43d8 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 11:00:06 -0700 Subject: [PATCH 03/50] hook: bound the leading-cd fold at 64 steps and walk past cds in a loop startDirectory folded every leading `cd DIR &&` onto the payload's cwd, joining and cleaning the whole growing path at each step, so the fold was quadratic: a 480 KB payload took 10.9 s against the 5 s hook timeout, and SIGTERM does not stop the work. Measured here before the change, 14,000 steps (the most the new token bound lets through) took 290 ms. shape.MaxLeadingCd = 64 is the bound, written down beside its reason. The hook folds no more: past it startDirectory returns the empty directory, which CWDDigest records as unknown, rather than the payload's cwd the cds moved away from or a fold stopped short of where the line ran. leadingDirectories stops reading one step past the bound. The masked-status walk holds the same bound: past it no step counts as held by the cwd digest, so the runner after them gets a null runner digest instead of one that would pair with a run in another directory. pastDirectoryChange, the program search's move past each leading cd, is a loop instead of a call per cd, since the step count is the input's to choose (CWE-674). The program is still named past any number of cds; the bound is the fold's, not the program's. The comment on MaxPayloadBytes said the read limit closes the out-of-memory path. It bounds the bytes read, not the work done on them; the comment now says so and names the two bounds that do the rest, the token bound (RES-9) and this one. Tests: TestRES10_LeadingCdFoldIsBounded (64 steps fold, 65 are unknown; a 1 MB `cd a&&` payload and 14,000 steps each fold within 50 ms, least of three runs), TestRES10_RunnerPastTheFoldIsNotPaired (64 and 65 steps through leadingSteps and Derive's runner digest), TestRES10_ProgramPastManyDirectoryChanges. Mutants in RES-10_leading-cd.py: the hook's bound lifted, the shared bound lifted, and the walk counting steps past the bound. The two FF mutants on pastDirectoryChange are re-anchored on the loop body and still red. Rubric fix list entries 14 (lld-1) and 29 (lld-6, the loop and the comment), RES-10. CWE-407, CWE-674. --- internal/hook/handle.go | 22 ++++++- internal/hook/res10_test.go | 68 ++++++++++++++++++++++ internal/shape/res10_test.go | 39 +++++++++++++ internal/shape/shape.go | 58 ++++++++++++------ test/mutation/mutants/FF_shape.py | 8 +-- test/mutation/mutants/RES-10_leading-cd.py | 10 ++++ 6 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 internal/hook/res10_test.go create mode 100644 internal/shape/res10_test.go create mode 100644 test/mutation/mutants/RES-10_leading-cd.py diff --git a/internal/hook/handle.go b/internal/hook/handle.go index 7e4be550..f9a59f83 100644 --- a/internal/hook/handle.go +++ b/internal/hook/handle.go @@ -18,10 +18,21 @@ import ( // exhausting memory is a runtime fatal error, which recover cannot catch and // which exits 2 -- and exit 2 from a PreToolUse hook blocks the user's tool // call. An unbounded read over an attacker-influenced tool_input is therefore -// a way to turn this recorder into an enforcer, and the limit is what closes -// it. hookio.Read enforces it; a refusal is recorded as payload_too_large. +// a way to turn this recorder into an enforcer. hookio.Read enforces the +// limit; a refusal is recorded as payload_too_large. +// +// The limit bounds the bytes read, not the work done on them, and alone it +// does not close that path: 8 MiB of one-letter words is four million shell +// words, and lexed whole the shape of that one line allocated 4.5 GB and +// drove a hook to about 1.07 GB of RSS. The work is bounded where it is done: +// the shape package stops lexing at its token bound (RES-9), and the +// leading-cd fold below stops at maxLeadingCd (RES-10). const MaxPayloadBytes = hookio.MaxPayloadBytes +// maxLeadingCd bounds the leading-cd fold in startDirectory: see +// shape.MaxLeadingCd, whose bound the masked-status walk shares. +const maxLeadingCd = shape.MaxLeadingCd + // UnattributedSession buckets records for a payload too malformed to name its // own session. Losing the run is bad; silently attributing it to a session that // did not produce it would be worse. @@ -237,10 +248,17 @@ func (h *Handler) Close(sig *Signals, captureErr error) { // on /repo and every repeat on /repo/web, although all of them ran in web. A // relative target with no directory to resolve it against is not known, and // the directory stays empty until an absolute target names one. +// +// Past maxLeadingCd steps the directory is not known, and is returned empty, +// which CWDDigest records as unknown: not the payload's cwd, which the cds +// moved away from, and not a fold stopped short of where the line ran. func startDirectory(cwd, toolName string, toolInput json.RawMessage) string { // The bool says whether the line had a leading run at all; with none, // dirs is empty and the payload's cwd is the answer, so it adds nothing. dirs, _ := shape.LeadingDirectory(toolName, toolInput) + if len(dirs) > maxLeadingCd { + return "" + } for _, dir := range dirs { switch { case filepath.IsAbs(dir): diff --git a/internal/hook/res10_test.go b/internal/hook/res10_test.go new file mode 100644 index 00000000..9ce88755 --- /dev/null +++ b/internal/hook/res10_test.go @@ -0,0 +1,68 @@ +package hook + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// cdLine is n leading `cd a &&` steps and then go test. +func cdLine(n int, sep string) string { + return strings.Repeat("cd a"+sep, n) + "go test ./..." +} + +// fastest is the least time any of three runs of f took: a deadline test +// compares the work, and the least of three keeps a busy machine's pauses out +// of the comparison. +func fastest(f func()) time.Duration { + best := time.Duration(1 << 62) + for i := 0; i < 3; i++ { + start := time.Now() + f() + best = min(best, time.Since(start)) + } + return best +} + +// TestRES10_LeadingCdFoldIsBounded: the leading-cd fold takes 64 steps and no +// more. At 64 the call's start directory is the payload's cwd with 64 steps +// folded on; at 65 it is not known, which CWDDigest records as unknown, +// rather than a directory the fold stopped short of. Each step joined and +// re-cleaned the whole growing path, so the fold was quadratic: a 480 KB +// payload took 10.9 s against the hook's 5 s timeout. A 1 MB `cd a&&` +// payload, and the longest fold the shape's token bound lets through, each +// finish inside 50 ms. +func TestRES10_LeadingCdFoldIsBounded(t *testing.T) { + in := func(cmd string) json.RawMessage { + raw, err := json.Marshal(map[string]string{"command": cmd}) + if err != nil { + t.Fatal(err) + } + return raw + } + want := "/repo" + strings.Repeat("/a", 64) + if got := startDirectory("/repo", "Bash", in(cdLine(64, " && "))); got != want { + t.Errorf("64 steps: start directory %q, want %q", got, want) + } + if got := startDirectory("/repo", "Bash", in(cdLine(65, " && "))); got != "" { + t.Errorf("65 steps: start directory %q, want unknown", got) + } + for _, tc := range []struct { + name string + cmd string + }{ + {"1 MB of cd a&&", strings.Repeat("cd a&&", (1<<20)/len("cd a&&"))}, + {"14,000 steps, under the token bound", cdLine(14_000, "&&")}, + } { + raw := in(tc.cmd) + var got string + took := fastest(func() { got = startDirectory("/repo", "Bash", raw) }) + if took > 50*time.Millisecond { + t.Errorf("%s: the fold took %v, want at most 50ms", tc.name, took) + } + if strings.Count(got, "/a") > 64 { + t.Errorf("%s: %d steps folded, want at most 64", tc.name, strings.Count(got, "/a")) + } + } +} diff --git a/internal/shape/res10_test.go b/internal/shape/res10_test.go new file mode 100644 index 00000000..f676b44a --- /dev/null +++ b/internal/shape/res10_test.go @@ -0,0 +1,39 @@ +package shape + +import ( + "strings" + "testing" +) + +// TestRES10_RunnerPastTheFoldIsNotPaired: the cwd digest holds at most +// MaxLeadingCd leading cd steps (the hook folds no more, and records the +// directory as unknown past them), so the masked-status walk counts no more +// as held either. At 64 steps the runner after them is digested as running +// where the cwd digest says; at 65 where it ran is not known, and its runner +// digest is null rather than one that pairs with a run somewhere else. +func TestRES10_RunnerPastTheFoldIsNotPaired(t *testing.T) { + line := func(n int) string { return strings.Repeat("cd a && ", n) + "make test" } + if got := leadingSteps(line(MaxLeadingCd)); got != MaxLeadingCd { + t.Errorf("%d steps: leadingSteps %d", MaxLeadingCd, got) + } + if got := leadingSteps(line(MaxLeadingCd + 1)); got != 0 { + t.Errorf("%d steps: leadingSteps %d, want 0: past the bound no step is held", MaxLeadingCd+1, got) + } + if s := Derive("Bash", bashInput(t, line(MaxLeadingCd)), benchKey); s.RunnerDigest == nil { + t.Errorf("%d steps: no runner digest", MaxLeadingCd) + } + if s := Derive("Bash", bashInput(t, line(MaxLeadingCd+1)), benchKey); s.RunnerDigest != nil { + t.Errorf("%d steps: runner digest %s, want null", MaxLeadingCd+1, *s.RunnerDigest) + } +} + +// TestRES10_ProgramPastManyDirectoryChanges: the program search moves past +// every leading `cd DIR &&` however many there are -- the cd bound is the +// fold's, not the program's -- and does it in a loop: 14,000 steps, the most +// the token bound lets through, name go. +func TestRES10_ProgramPastManyDirectoryChanges(t *testing.T) { + s := Derive("Bash", bashInput(t, strings.Repeat("cd a && ", 14_000)+"go test ./..."), benchKey) + if s.Program == nil || *s.Program != "go" { + t.Errorf("program %v, want go", show(s.Program)) + } +} diff --git a/internal/shape/shape.go b/internal/shape/shape.go index 3a455a54..06e2f53d 100644 --- a/internal/shape/shape.go +++ b/internal/shape/shape.go @@ -416,27 +416,31 @@ func programToken(toks []token, uncertain bool) (int, bool) { // nothing here either. The command after it is searched by programToken and // held to every rule the line's first command is. A comment before the // separator makes it comment text, and `cd` is then the whole command. +// +// It is a loop, one step per cd, rather than a call per cd: a line of tens +// of thousands of them is within the token bound, and its depth is the +// input's to choose (RES-10, CWE-674). func pastDirectoryChange(toks []token, i int, uncertain bool) (int, bool) { - if toks[i].text != "cd" || toks[i].quotedAt >= 0 { - return i, true - } - sep, ok := commandEnd(toks, i, uncertain) - if !ok { - return 0, false - } - if sep < 0 || toks[sep].text != "&&" && toks[sep].text != ";" { - return i, true - } - for j := i + 1; j < sep; j++ { - if isComment(toks[j]) { + for toks[i].text == "cd" && toks[i].quotedAt < 0 { + sep, ok := commandEnd(toks, i, uncertain) + if !ok { + return 0, false + } + if sep < 0 || toks[sep].text != "&&" && toks[sep].text != ";" { return i, true } + for j := i + 1; j < sep; j++ { + if isComment(toks[j]) { + return i, true + } + } + k, ok := programToken(toks[sep+1:], uncertain) + if !ok { + return 0, false + } + i = sep + 1 + k } - k, ok := programToken(toks[sep+1:], uncertain) - if !ok { - return 0, false - } - return pastDirectoryChange(toks, sep+1+k, uncertain) + return i, true } // LeadingDirectory reports the directories a shell call's line moves through @@ -467,12 +471,25 @@ func LeadingDirectory(toolName string, toolInput json.RawMessage) ([]string, boo return leadingDirectories(cmd) } +// MaxLeadingCd is how many leading `cd DIR &&` steps a call's start +// directory is folded from (RES-10). The hook folds no more and records the +// directory as unknown past them: each step joins and cleans the whole path +// so far, so the fold is quadratic in the steps, and a 480 KB payload of +// them took 10.9 s against the hook's 5 s timeout. 64 is far past any line +// an agent writes -- one or two steps is the norm -- and folds in well under +// a millisecond. +const MaxLeadingCd = 64 + // leadingSteps is how many leading `cd DIR &&` steps LeadingDirectory folds // onto a call's cwd for the line cmd: the cds on it that the call's cwd -// digest already holds. +// digest already holds. None past MaxLeadingCd, where the hook records the +// directory as unknown and the digest holds none of them. func leadingSteps(cmd string) int { // Where the line cannot be read, dirs is empty and no step is folded. dirs, _ := leadingDirectories(cmd) + if len(dirs) > MaxLeadingCd { + return 0 + } return len(dirs) } @@ -502,6 +519,11 @@ func leadingDirectories(cmd string) ([]string, bool) { return nil, false } dirs = append(dirs, dir) + if len(dirs) > MaxLeadingCd { + // One past the bound says the line is past it; the + // rest would be read for nothing. + break + } } return dirs, len(dirs) > 0 } diff --git a/test/mutation/mutants/FF_shape.py b/test/mutation/mutants/FF_shape.py index e5d0b5b0..8dbf5f73 100644 --- a/test/mutation/mutants/FF_shape.py +++ b/test/mutation/mutants/FF_shape.py @@ -14,12 +14,12 @@ "\t\tif i, ok = pastDirectoryChange(pshaped, i, uncertain); ok {", "\t\tif i, ok = i, true; ok {", "TestProgramLooksPastADirectoryChange|TestProgramIsAProgram") m("FF the separator after cd is found without the command-end scan", "internal/shape/shape.go", - "\tsep, ok := commandEnd(toks, i, uncertain)\n\tif !ok {\n\t\treturn 0, false\n\t}\n", - "\tsep := -1\n\tfor j := i + 1; j < len(toks); j++ {\n\t\tif toks[j].meta {\n\t\t\tsep = j\n\t\t\tbreak\n\t\t}\n\t}\n", + "\t\tsep, ok := commandEnd(toks, i, uncertain)\n\t\tif !ok {\n\t\t\treturn 0, false\n\t\t}\n", + "\t\tsep := -1\n\t\tfor j := i + 1; j < len(toks); j++ {\n\t\t\tif toks[j].meta {\n\t\t\t\tsep = j\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n", "TestProgramLooksPastADirectoryChange") m("FF a separator in a comment after cd is followed", "internal/shape/shape.go", - "\t\tif isComment(toks[j]) {\n\t\t\treturn i, true\n", - "\t\tif false && isComment(toks[j]) {\n\t\t\treturn i, true\n", + "\t\t\tif isComment(toks[j]) {\n\t\t\t\treturn i, true\n", + "\t\t\tif false && isComment(toks[j]) {\n\t\t\t\treturn i, true\n", "TestProgramLooksPastADirectoryChange") m("FF a refused turn is never reported", "internal/recap/state.go", "\treturn s.Checked[sessionID] != promptID", "\treturn s.Checked[sessionID] != promptID && false", diff --git a/test/mutation/mutants/RES-10_leading-cd.py b/test/mutation/mutants/RES-10_leading-cd.py new file mode 100644 index 00000000..ecdb86f7 --- /dev/null +++ b/test/mutation/mutants/RES-10_leading-cd.py @@ -0,0 +1,10 @@ +# RES-10: the leading-cd fold stops at 64 steps, past which the start +# directory is unknown, and the masked-status walk holds no step past the +# same bound. Lifting either bound, or counting steps the cwd digest does not +# hold, is the regression. +m("RES-10 the hook folds every leading cd", "internal/hook/handle.go", + "const maxLeadingCd = shape.MaxLeadingCd\n", "const maxLeadingCd = 1 << 30\n", "TestRES10_") +m("RES-10 the shared leading-cd bound is lifted", "internal/shape/shape.go", + "const MaxLeadingCd = 64\n", "const MaxLeadingCd = 1 << 30\n", "TestRES10_") +m("RES-10 a runner past the fold is paired as though its directory were known", "internal/shape/shape.go", + "\tif len(dirs) > MaxLeadingCd {\n\t\treturn 0\n\t}\n", "", "TestRES10_RunnerPastTheFoldIsNotPaired") From 304fcfa8b383abce8bc273120e4773ddbc165d1b Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 11:01:52 -0700 Subject: [PATCH 04/50] shape: pin maxNest at its deepest level and one past it maxNest is the only barrier between a line of nested substitutions and a stack overflow, which is a fatal error that exits 2, and exit 2 from a PreToolUse hook blocks the tool call. Nothing tested it: no test named it, and raising it to 1 << 30 passed every suite while its binary crashed on a 4.6 MB payload. TestRES29_NestingAtAndPastMaxNest has one row per construct that recurses: a quoted string inside each substitution ("$(echo " costs two levels, so the flip is between 16 and 17 pairs), a substitution inside a substitution and arithmetic inside arithmetic (one level each, 33 and 34). Each line is `cd "..." && ls`: at the deepest accepted level the program is ls, one past it the reading gives up inside the word and the program is null, and both answers come within 50 ms. Mutants in RES-29_max-nest.py: the bound lifted to 1 << 30, moved to 31 and to 33, and the check dropped from each of comsubEnd, dquoteEnd and arithEnd. The code is unchanged. Rubric fix list entry 16 (lld-5), the maxNest row of RES-29. CWE-674. --- internal/shape/res29_test.go | 72 ++++++++++++++++++++++++ test/mutation/mutants/RES-29_max-nest.py | 19 +++++++ 2 files changed, 91 insertions(+) create mode 100644 internal/shape/res29_test.go create mode 100644 test/mutation/mutants/RES-29_max-nest.py diff --git a/internal/shape/res29_test.go b/internal/shape/res29_test.go new file mode 100644 index 00000000..9b8b7bed --- /dev/null +++ b/internal/shape/res29_test.go @@ -0,0 +1,72 @@ +package shape + +import ( + "strings" + "testing" + "time" +) + +// TestRES29_NestingAtAndPastMaxNest: maxNest is the only barrier between a +// line of nested substitutions and a stack overflow, which is a fatal error +// that exits 2 -- and exit 2 from PreToolUse blocks the call. One row per +// nesting construct that recurses, at the deepest level each accepts and one +// past it. Each line is `cd "..." && ls`: read to its end, the cd's argument +// closes, the && after it is found, and the program is ls; past the bound the +// reading gives up inside the word, where the command after cd cannot be +// found, and the program is null. Either way the answer comes at once. +// +// A "$(echo " pair costs two levels, the substitution and the quoted string +// inside it, so the flip is between 16 and 17 pairs; a bare $( ) or a $(( )) +// inside another costs one, so it is between 33 and 34. +func TestRES29_NestingAtAndPastMaxNest(t *testing.T) { + quoted := func(n int) string { + return `cd "` + strings.Repeat(`$(echo "`, n) + "x" + strings.Repeat(`")`, n) + `" && ls` + } + bare := func(n int) string { + return `cd "` + strings.Repeat("$(echo ", n) + "x" + strings.Repeat(")", n) + `" && ls` + } + arith := func(n int) string { + return `cd "` + strings.Repeat("$(( 1 + ", n) + "1" + strings.Repeat(" ))", n) + `" && ls` + } + for _, tc := range []struct { + name string + line func(int) string + deepest int + perLevel int + }{ + {"a quoted string inside each substitution", quoted, 16, 2}, + {"a substitution inside a substitution", bare, 33, 1}, + {"arithmetic inside arithmetic", arith, 33, 1}, + } { + if (tc.deepest-1)*tc.perLevel > maxNest { + t.Fatalf("%s: %d levels is past maxNest %d before the last one", tc.name, tc.deepest, maxNest) + } + for _, n := range []int{tc.deepest, tc.deepest + 1} { + in := bashInput(t, tc.line(n)) + var s Shape + took := fastest(func() { s = Derive("Bash", in, benchKey) }) + if took > 50*time.Millisecond { + t.Errorf("%s, %d levels: %v, want at most 50ms", tc.name, n, took) + } + accepted := n == tc.deepest + switch { + case accepted && (s.Program == nil || *s.Program != "ls"): + t.Errorf("%s, %d levels (the deepest accepted): program %v, want ls", tc.name, n, show(s.Program)) + case !accepted && s.Program != nil: + t.Errorf("%s, %d levels (one past maxNest): program %s, want null", tc.name, n, *s.Program) + } + } + } +} + +// fastest is the least time any of three runs of f took, so a busy machine's +// pauses stay out of a deadline comparison. +func fastest(f func()) time.Duration { + best := time.Duration(1 << 62) + for i := 0; i < 3; i++ { + start := time.Now() + f() + best = min(best, time.Since(start)) + } + return best +} diff --git a/test/mutation/mutants/RES-29_max-nest.py b/test/mutation/mutants/RES-29_max-nest.py new file mode 100644 index 00000000..265dc5b2 --- /dev/null +++ b/test/mutation/mutants/RES-29_max-nest.py @@ -0,0 +1,19 @@ +# RES-29 (rubric lld-5, fix list 16): maxNest is the only barrier between +# nested substitutions and a stack overflow, which exits 2 and blocks the +# call. Lifting it, moving it by one either way, or dropping the check at any +# of the three places that recurse is the regression. +m("RES-29 maxNest is lifted", "internal/shape/comsub.go", + "const maxNest = 32\n", "const maxNest = 1 << 30\n", "TestRES29_") +m("RES-29 maxNest refuses its deepest level", "internal/shape/comsub.go", + "const maxNest = 32\n", "const maxNest = 31\n", "TestRES29_") +m("RES-29 maxNest accepts one level past it", "internal/shape/comsub.go", + "const maxNest = 32\n", "const maxNest = 33\n", "TestRES29_") +m("RES-29 a substitution recurses without the nesting check", "internal/shape/comsub.go", + "func (r joined) comsubEnd(i, nest int) int {\n\tif nest > maxNest {", + "func (r joined) comsubEnd(i, nest int) int {\n\tif false {", "TestRES29_") +m("RES-29 a quoted string recurses without the nesting check", "internal/shape/comsub.go", + "func (r joined) dquoteEnd(i, nest int) int {\n\tif nest > maxNest {", + "func (r joined) dquoteEnd(i, nest int) int {\n\tif false {", "TestRES29_") +m("RES-29 arithmetic recurses without the nesting check", "internal/shape/comsub.go", + "func (r joined) arithEnd(i, nest int) int {\n\tif nest > maxNest {", + "func (r joined) arithEnd(i, nest int) int {\n\tif false {", "TestRES29_") From 2ab2beb46b77fbece816082966aa80d42d76c7d1 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 11:09:02 -0700 Subject: [PATCH 05/50] host: one spelling per address, a shape check and a length bound for named hosts Two defects in how a hostname reaches the record. Shape and length (lld-2). Only the ssh path asked whether a token was a host; the URL path took whatever authority() cut at the command line's punctuation, and `=` and `!` end neither a URL nor a host. So `curl https://api.example.com=ghp_...!x/` recorded the host "api.example.com=ghp_...!x", a token in the field that holds the hostnames a call named, and with no length limit an authority of 300,000 bytes was recorded whole. host.Shaped is now the one predicate: a bracketed IPv6 address with no zone, or a name of at most 253 bytes of ASCII letters, digits, `-`, `_` and `.` or non-ASCII letters, marks and digits, with no empty label. shape.collect asks it after Canonical, for Bash and WebFetch alike, and the ssh destination path asks it in place of its own ASCII-only copy, which is removed. Canonical itself takes only the 253-byte bound, since it also serves the sandbox reader. The wire-side half of the entry (a Refused counter on the proxy's Observation) is moot: internal/wire was deleted with the proxy. Spellings (lld-4). Canonical lower-cased and stripped ports but kept IP literals as written, while a client normalises them before it connects: curl turns [2606:4700:0:0:0:0:0:1111] into [2606:4700::1111] and 167838211 into 10.1.2.3. The proxy comparison this broke is gone, but the consequence is live in two places: the novelty baseline, where one address in two spellings is two hosts and the second reads as new, and the sandbox reader (internal/nono), whose targets are compared with the declared hosts. IP literals are now parsed with net/netip and written once: IPv6 compressed, lower-case and bracketed (a bare one gains its brackets), an IPv4-mapped address as the IPv4 address it maps, and every form inet_aton reads (167838211, 0xa010203, 012.1.2.3, 10.1.515) as a dotted quad. Numeric-looking names that are not addresses (1.2.3.4.5, 256.1.1.1, 08.1.2.3) stay names. A bracket that holds anything but an IPv6 address, and a colon outside one, are refused. net/netip is not package net: it opens nothing, and net/url already linked it into the recorder, so H-17 holds unchanged. Loopback was a list of five spellings in the report, so [0:0:0:0:0:0:0:1], 127.0.0.2, 2130706433 and api.localhost were offered to the baseline as possible new destinations. host.Loopback decides it on the parsed address (127.0.0.0/8, ::1, the unspecified address) and on localhost and names under .localhost (RFC 6761 6.3). IDNA is not implemented: it needs golang.org/x/net/idna, a new module dependency and a THIRD_PARTY_NOTICES entry, which is a decision this change does not take. The comment that justified its absence by "the two sides receive the same bytes" was false and now says what is lost: a Unicode name and its xn-- form are two hosts, and every such mismatch errs toward a host read as new, never one hidden. The proxy-era comments in the package doc and in Canonical's are rewritten for the readers that exist. Tests: TestRubricLLD2_ShapedAcceptsHostnamesOnly, TestRubricLLD2_CanonicalBoundsItsLength (253 and 254 bytes), TestRubricLLD2_URLHostsAreHostnameShaped (the `=` and `!` rows, 300,000 bytes, 253 and 254, an IDN, an IPv6 and an inet_aton URL, and WebFetch), TestRubricLLD4_SpellingFamiliesCanonicaliseAlike (six families including 10.1.2.3 against [::ffff:10.1.2.3], and names that must stay names), TestRubricLLD4_LoopbackIsParsed, TestRubricLLD4_LoopbackSpellingsAreNotNovel. TestCanonical's bare-IPv6 row now expects the bracketed form. Mutants in RUBRIC_lld-2_host-shape.py (five) and RUBRIC_lld-4_spellings.py (eight); the H-70 shape mutant is re-anchored on host.Shaped and still red. Rubric fix list entries 10 (lld-2) and 30 (lld-4). CWE-20, CWE-176. --- internal/host/host.go | 222 +++++++++++++++--- internal/host/host_test.go | 4 +- internal/host/shape_test.go | 151 ++++++++++++ internal/report/destinations.go | 16 +- internal/report/rubric_lld4_test.go | 22 ++ internal/shape/hosts.go | 53 +---- internal/shape/hosts_test.go | 41 ++++ test/mutation/mutants/H-70_hosts.py | 2 +- test/mutation/mutants/NONO_nono.py | 5 +- .../mutants/RUBRIC_lld-2_host-shape.py | 14 ++ .../mutants/RUBRIC_lld-4_spellings.py | 26 ++ 11 files changed, 472 insertions(+), 84 deletions(-) create mode 100644 internal/host/shape_test.go create mode 100644 internal/report/rubric_lld4_test.go create mode 100644 test/mutation/mutants/RUBRIC_lld-2_host-shape.py create mode 100644 test/mutation/mutants/RUBRIC_lld-4_spellings.py diff --git a/internal/host/host.go b/internal/host/host.go index df047c3c..bd826a80 100644 --- a/internal/host/host.go +++ b/internal/host/host.go @@ -1,12 +1,15 @@ // Package host canonicalises a hostname to the one spelling every side of the // product compares on. // -// It exists because a hostname arrives here from more than one place and has -// to be the same string in all of them: the declaration side reads it out of -// a tool call the agent wrote, and the sandbox side reads it out of nono's -// audit trail. "PyPI.org", "pypi.org:443" and "pypi.org." are one destination, -// and a report that treated them as three would count traffic the sandbox saw -// as unrelated to a host the agent, in different case, had already named. +// It exists because a hostname arrives here from unrelated places and has to +// be the same string in each: the declaration side reads it out of a tool call +// the agent wrote, the novelty baseline compares it against every earlier +// session's, and a sandbox's network record (internal/nono) names the target +// it saw. "PyPI.org", "pypi.org:443" and "pypi.org." are one destination, and +// so are "[2606:4700:0:0:0:0:0:1111]" and "[2606:4700::1111]", and "167838211" +// and "10.1.2.3" -- a client normalises an IP literal before it connects. A +// comparison that treats them as different answers wrongly: a host named in +// one spelling reads as new, or as never named, when it was. // // A hostname is the most sensitive field this product stores. It can carry a // tenant, a bucket, or an internal topology. That is why this package returns a @@ -16,39 +19,54 @@ package host import ( + "net/netip" "net/url" + "strconv" "strings" + "unicode" + "unicode/utf8" ) +// maxName is the longest a hostname can be: 253 bytes in its text form +// (RFC 1035 2.3.4, less the root label's length byte and the trailing dot). +// Nothing longer is returned, so a fragment of a command line cannot be +// recorded as one host however long it is. +const maxName = 253 + // Canonical reduces a raw authority or URL-ish string to a comparable // hostname, reporting false when the input yields none. // // The transformations, and why each one is needed rather than defensive: // -// - lower-cased, because DNS is case-insensitive and both sides spell it -// however their source did; +// - lower-cased, because DNS is case-insensitive and every source spells it +// however it likes; // - port stripped, because pypi.org:443 and pypi.org are one destination and -// the sandbox's trail records the port while a tool call usually does -// not; +// a network record carries the port while a tool call usually does not; // - userinfo REJECTED outright rather than stripped (see below); -// - IPv6 brackets kept, because "::1" without them is ambiguous against a -// host:port split and every consumer here expects the bracketed form; +// - an IP literal written in its one spelling (address): an IPv6 address +// compressed, lower-case and in brackets, because "::1" without them is +// ambiguous against a host:port split; an IPv4-mapped one as the IPv4 +// address it maps; an IPv4 address in any form inet_aton reads -- +// 167838211, 0xa010203, 012.1.2.3, 10.1.515 -- as a dotted quad. A +// bracket holding anything but an IPv6 address, and a colon outside one, +// is refused: neither is a host; // - one trailing dot dropped, because "pypi.org." is the fully-qualified -// spelling of the same name and only one of the two sides ever emits it; +// spelling of the same name; +// - at most 253 bytes (maxName); // - loopback KEPT. Filtering it is the report's decision, not this -// function's: the sandbox section needs to see loopback rows in order to -// exclude them knowingly, and a canonicaliser that silently dropped them -// would make "no loopback traffic" and "loopback traffic we discarded" the -// same answer. +// function's (Loopback): a canonicaliser that silently dropped it would +// make "no loopback traffic" and "loopback traffic we discarded" the same +// answer. // // Userinfo is rejected, not stripped, and that is a security decision // (CWE-522). A string like "user:password@internal.example" carries a // credential, and stripping it would leave this function returning a clean // hostname for an input we then have to trust every caller never logged. There -// is no legitimate source of userinfo in this product's inputs: nono's trail -// carries a bare authority, and a URL with embedded credentials in a tool call -// is a thing we specifically do not want to record the existence of. Refusing -// it means a credential cannot be carried even one function deeper. +// is no legitimate source of userinfo in this product's inputs: a network +// record's target is a bare authority, and a URL with embedded +// credentials in a tool call is a thing we specifically do not want to record +// the existence of. Refusing it means a credential cannot be carried even one +// function deeper. func Canonical(raw string) (string, bool) { s := strings.TrimSpace(raw) if s == "" { @@ -92,15 +110,158 @@ func Canonical(raw string) (string, bool) { if !plausible(h) { return "", false } + return address(h) +} + +// address writes an IP literal h in its one spelling (see Canonical) and +// returns any other host as it is. A bracket that does not hold an IPv6 +// address, or a colon outside a bracket that does not make one, is refused. +func address(h string) (string, bool) { + inner, bracketed := h, strings.HasPrefix(h, "[") + if bracketed { + inner = h[1 : len(h)-1] + } + if bracketed || strings.IndexByte(h, ':') >= 0 { + a, err := netip.ParseAddr(inner) + if err != nil || !a.Is6() { + return "", false + } + return spell(a), true + } + if a, ok := inetAton(h); ok { + return a.String(), true + } return h, true } +// spell is an address's one spelling: an IPv4-mapped IPv6 address as the +// IPv4 address it maps, any other IPv6 address compressed, lower-case and in +// brackets. +func spell(a netip.Addr) string { + if a = a.Unmap(); a.Is4() { + return a.String() + } + return "[" + a.String() + "]" +} + +// inetAton reads h as an IPv4 address the way inet_aton(3) does, which is how +// curl, ssh and the resolver read a host that is all numbers: one to four +// parts separated by dots, each decimal, octal with a leading 0, or hex with +// 0x; the last part fills the bytes the others leave, so 10.1.515 is +// 10.1.2.3 and so is 167838211. Anything else is not an address and stays a +// name -- 1.2.3.4.5, 256.1.1.1 and 08.1.2.3 are resolved, not parsed. +func inetAton(h string) (netip.Addr, bool) { + parts := strings.Split(h, ".") + if len(parts) > 4 { + return netip.Addr{}, false + } + var v uint64 + for i, p := range parts { + n, ok := atonPart(p) + if !ok { + return netip.Addr{}, false + } + // The last part takes every byte left: four for a lone number, one + // for the fourth of four. Every other part takes one. + width := uint(8) + if i == len(parts)-1 { + width = 8 * uint(5-len(parts)) + } + if n >= 1<> 24), byte(v >> 16), byte(v >> 8), byte(v)}), true +} + +// atonPart reads one part of an inet_aton address: hex after 0x, octal after +// a leading 0, decimal otherwise, at most 32 bits. +func atonPart(p string) (uint64, bool) { + base := 10 + switch { + case len(p) > 2 && (p[:2] == "0x" || p[:2] == "0X"): + p, base = p[2:], 16 + case len(p) > 1 && p[0] == '0': + p, base = p[1:], 8 + } + if p == "" || p[0] == '+' || p[0] == '-' { + return 0, false + } + n, err := strconv.ParseUint(p, base, 32) + return n, err == nil +} + +// Shaped reports whether s has the shape of a host as Canonical returns one: +// a bracketed IPv6 address with no zone, or a name of at most 253 bytes made +// of ASCII letters, digits, '-', '_' and '.', or non-ASCII letters, marks and +// digits for an internationalised name, with no empty label, no '-' or '.' +// at its start and no '-' at its end. One trailing dot is allowed: it is the +// fully-qualified spelling, which Canonical drops. +// +// Canonical is permissive about the bytes a name may hold on purpose: it +// serves every caller, and an over-strict rule there would silently drop +// real internal hostnames. Shaped is the declaration side's question, asked +// where a host is cut out of a command line: is this a host at all? A URL's +// authority is cut where a command line's punctuation begins, which is not +// where a host ends -- `=` and `!` are neither -- so without it +// `https://api.example.com=ghp_...!x/` named a "host" carrying a token, and a +// field documented as the hostnames a call named held text that was not one. +func Shaped(s string) bool { + if strings.HasPrefix(s, "[") { + if !strings.HasSuffix(s, "]") { + return false + } + a, err := netip.ParseAddr(s[1 : len(s)-1]) + return err == nil && a.Is6() && a.Zone() == "" + } + if s == "" || len(s) > maxName || !utf8.ValidString(s) { + return false + } + if s[0] == '-' || s[0] == '.' || s[len(s)-1] == '-' || strings.Contains(s, "..") { + return false + } + alnum := false + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + alnum = true + case r == '-' || r == '.' || r == '_': + case r >= utf8.RuneSelf && (unicode.IsLetter(r) || unicode.IsMark(r) || unicode.IsDigit(r)): + alnum = true + default: + return false + } + } + return alnum +} + +// Loopback reports whether h names this machine: localhost or a name under +// .localhost (RFC 6761 6.3), an address in 127.0.0.0/8 or ::1, or the +// unspecified address, a connection to which reaches this machine. It is +// decided on the parsed address, so every spelling of one -- +// [0:0:0:0:0:0:0:1], 2130706433, 127.1 -- answers alike, where a list of +// spellings answered only for the spellings on it. +func Loopback(h string) bool { + c, ok := Canonical(h) + if !ok { + return false + } + if c == "localhost" || strings.HasSuffix(c, ".localhost") { + return true + } + a, err := netip.ParseAddr(strings.TrimSuffix(strings.TrimPrefix(c, "["), "]")) + return err == nil && (a.IsLoopback() || a.IsUnspecified()) +} + // stripPort removes a :port suffix from a bare authority, leaving a bracketed // IPv6 literal intact. // // It does not use net.SplitHostPort: that lives in package net, which the // recorder's dependency graph must not contain (H-17), and the only thing -// needed here is the last colon outside the brackets. +// needed here is the last colon outside the brackets. net/netip, which +// address uses, is not package net: it opens nothing and imports no socket +// code, and net/url already brings it into the graph. func stripPort(h string) string { if strings.HasPrefix(h, "[") { end := strings.LastIndex(h, "]") @@ -124,16 +285,19 @@ func stripPort(h string) string { // is not a validator and an over-strict rule here would silently drop real // internal hostnames, which are the most interesting ones in the report. It // refuses only what would corrupt a record or a comparison -- whitespace, a -// path or query that survived parsing, control bytes, and a bracketed form that -// is not closed. +// path or query that survived parsing, control bytes, a bracketed form that +// is not closed, and more than maxName bytes. // // Non-ASCII names pass through lower-cased rather than being converted to -// punycode. IDNA-to-ASCII is NOT implemented (see the package note in the -// commit): the two sides of the comparison receive the same bytes from the same -// session, so they still match each other; what is lost is that a Unicode name -// and its xn-- spelling would compare unequal. +// punycode, because IDNA-to-ASCII is not implemented: it needs +// golang.org/x/net/idna, a new module dependency this package has not taken. +// So a Unicode name and its xn-- spelling compare unequal: bücher.example +// named in one call and xn--bcher-kva.example in another are two hosts to +// the baseline, and a sandbox record, which carries the xn-- form a client +// resolved, does not match the Unicode name a command line held. Every such +// mismatch errs toward a host read as new, never toward one hidden. func plausible(h string) bool { - if h == "" || h == "." { + if h == "" || h == "." || len(h) > maxName { return false } if strings.HasPrefix(h, "[") != strings.HasSuffix(h, "]") { diff --git a/internal/host/host_test.go b/internal/host/host_test.go index 2b1cdae1..2a075e9d 100644 --- a/internal/host/host_test.go +++ b/internal/host/host_test.go @@ -31,8 +31,8 @@ func TestCanonical(t *testing.T) { {name: "ipv6 brackets kept via authority", in: "[::1]:443", want: "[::1]", ok: true}, {name: "ipv6 brackets restored via url", in: "https://[2606:4700::1111]/x", want: "[2606:4700::1111]", ok: true, why: "url.Hostname strips the brackets and every consumer here expects them"}, - {name: "bare ipv6 without port keeps its colons", in: "2606:4700::1111", want: "2606:4700::1111", ok: true, - why: "more than one colon cannot be a port separator"}, + {name: "bare ipv6 without port is bracketed", in: "2606:4700::1111", want: "[2606:4700::1111]", ok: true, + why: "more than one colon cannot be a port separator, and the bracketed form is the one spelling"}, {name: "wss scheme", in: "wss://gateway.example.com/socket", want: "gateway.example.com", ok: true}, // Refusals. diff --git a/internal/host/shape_test.go b/internal/host/shape_test.go new file mode 100644 index 00000000..95d1e4c1 --- /dev/null +++ b/internal/host/shape_test.go @@ -0,0 +1,151 @@ +package host + +import ( + "strings" + "testing" +) + +// label63 is a 63-byte label, the longest DNS allows. +var label63 = strings.Repeat("a", 63) + +// name is a hostname of exactly n bytes made of 63-byte labels. +func name(n int) string { + var b strings.Builder + for b.Len() < n { + if b.Len() > 0 { + b.WriteByte('.') + } + b.WriteString(label63[:min(63, n-b.Len())]) + } + return b.String() +} + +// TestRubricLLD2_ShapedAcceptsHostnamesOnly: Shaped is the declaration side's +// question "is this a host at all". A URL's authority is cut where a command +// line's punctuation begins, which is not where a host ends: `=` and `!` are +// neither, so `https://api.example.com=ghp_...!x/` came back as a "host" +// carrying a token, and with no length bound a 300,008-byte one did too, and +// both were recorded as hostnames the call named. +func TestRubricLLD2_ShapedAcceptsHostnamesOnly(t *testing.T) { + for _, tc := range []struct { + in string + want bool + }{ + {"api.example.com", true}, + {"API.Example.COM", true}, + {"pypi.org.", true}, + {"build_box", true}, + {"localhost", true}, + {"10.1.2.3", true}, + {"[2606:4700::1111]", true}, + {"[::1]", true}, + {"bücher.example", true}, + {"例え.jp", true}, + {name(253), true}, + + {"api.example.com=ghp_q4br7xyzsecrettok3n!x", false}, + {"h.example.com!x", false}, + {"host=bad", false}, + {name(254), false}, + {"", false}, + {".", false}, + {"-leading.example", false}, + {"trailing-", false}, + {".example.com", false}, + {"a..b", false}, + {"---", false}, + {"[]", false}, + {"[not-an-address]", false}, + {"[10.1.2.3]", false}, + {"[fe80::1%eth0]", false}, + {"2606:4700::1111", false}, + {"a b", false}, + {"bad\xffutf8", false}, + {"emoji😀.example", false}, + } { + if got := Shaped(tc.in); got != tc.want { + t.Errorf("Shaped(%.40q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +// TestRubricLLD2_CanonicalBoundsItsLength: no hostname is longer than 253 +// bytes, so Canonical returns none: 253 bytes is one, 254 is not. +func TestRubricLLD2_CanonicalBoundsItsLength(t *testing.T) { + if got, ok := Canonical(name(253)); !ok || got != name(253) { + t.Errorf("253 bytes refused") + } + if _, ok := Canonical(name(254)); ok { + t.Errorf("254 bytes accepted") + } + if _, ok := Canonical("https://" + name(254) + "/x"); ok { + t.Errorf("254 bytes accepted through a URL") + } +} + +// TestRubricLLD4_SpellingFamiliesCanonicaliseAlike: a client normalises an IP +// literal before it connects -- curl turns [2606:4700:0:0:0:0:0:1111] into +// [2606:4700::1111] and 167838211 into 10.1.2.3 -- so every spelling of one +// address has to reach the one string the other side compares on. Each row +// is a family; every member canonicalises to the first column. +func TestRubricLLD4_SpellingFamiliesCanonicaliseAlike(t *testing.T) { + for _, tc := range []struct { + want string + members []string + }{ + {"[2606:4700::1111]", []string{ + "[2606:4700::1111]", "[2606:4700:0:0:0:0:0:1111]", "[2606:4700:0000:0000:0000:0000:0000:1111]", + "2606:4700::1111", "2606:4700:0:0:0:0:0:1111", "[2606:4700::1111]:443", + "https://[2606:4700:0:0:0:0:0:1111]:443/x", "[2606:4700::1111].", + }}, + {"[2606:4700::abcd]", []string{"[2606:4700::ABCD]", "https://[2606:4700:0::AbCd]/"}}, + {"10.1.2.3", []string{ + "10.1.2.3", "10.1.2.3:443", "167838211", "0xa010203", "0XA010203", "012.1.2.3", + "10.1.515", "10.66051", "[::ffff:10.1.2.3]", "[::ffff:a01:203]", "https://167838211/x", "10.1.2.3.", + }}, + {"127.0.0.1", []string{"127.1", "2130706433", "0x7f000001", "0177.0.0.1", "[::ffff:127.0.0.1]"}}, + {"[::1]", []string{"[::1]", "[0:0:0:0:0:0:0:1]", "::1", "http://[0::1]:8080/"}}, + {"0.0.0.0", []string{"0", "0.0.0.0", "0x0"}}, + } { + for _, in := range tc.members { + if got, ok := Canonical(in); !ok || got != tc.want { + t.Errorf("Canonical(%q) = %q, %v; want %q", in, got, ok, tc.want) + } + } + } + // Numeric-looking names that are not addresses stay names, as a client + // resolves them, rather than becoming an address they are not. + for _, in := range []string{"1.2.3.4.5", "256.1.1.1", "4294967296", "1.2.3.256", "08.1.2.3", "0x", "1e100.net", "0x1g"} { + if got, ok := Canonical(in); !ok || got != strings.ToLower(in) { + t.Errorf("Canonical(%q) = %q, %v; want it unchanged", in, got, ok) + } + } + // A bracket holds an IPv6 address or nothing this function returns. + for _, in := range []string{"[not-an-address]", "[10.1.2.3]", "[]", "a:b:c"} { + if got, ok := Canonical(in); ok { + t.Errorf("Canonical(%q) = %q, want refused", in, got) + } + } +} + +// TestRubricLLD4_LoopbackIsParsed: whether a host is this machine is decided +// on the parsed address, not by membership in a list of spellings, so +// [0:0:0:0:0:0:0:1], 2130706433 and 127.0.0.2 are loopback as [::1] and +// 127.0.0.1 are, and a name under .localhost is (RFC 6761 6.3). +func TestRubricLLD4_LoopbackIsParsed(t *testing.T) { + for _, in := range []string{ + "localhost", "LOCALHOST", "api.localhost", "127.0.0.1", "127.0.0.2", "127.255.255.254", "2130706433", + "127.1", "[::1]", "[0:0:0:0:0:0:0:1]", "::1", "0.0.0.0", "0", "[::]", "[::ffff:127.0.0.1]", "localhost:3000", + } { + if !Loopback(in) { + t.Errorf("Loopback(%q) = false", in) + } + } + for _, in := range []string{ + "example.com", "localhost.example.com", "mylocalhost", "10.0.0.1", "128.0.0.1", "[::2]", "[2606:4700::1111]", "", "user@localhost", + } { + if Loopback(in) { + t.Errorf("Loopback(%q) = true", in) + } + } +} diff --git a/internal/report/destinations.go b/internal/report/destinations.go index 5110ad7e..2b1cbb5a 100644 --- a/internal/report/destinations.go +++ b/internal/report/destinations.go @@ -6,6 +6,7 @@ import ( "time" "github.com/altrace-dev-role/rashomon/internal/baseline" + "github.com/altrace-dev-role/rashomon/internal/host" "github.com/altrace-dev-role/rashomon/internal/store" ) @@ -92,13 +93,6 @@ type Novelty struct { Hosts []string `json:"hosts"` } -// loopbackHosts are listed among the declared hosts and never counted as -// novel: a local server is not a new place the project's traffic goes, first -// baseline entry or not. -var loopbackHosts = map[string]bool{ - "localhost": true, "127.0.0.1": true, "0.0.0.0": true, "[::1]": true, "::1": true, -} - // clientPlaneHosts are the hosts the client contacts on its own behalf, // regardless of the agent's tool calls. // @@ -173,14 +167,18 @@ func buildDestinations(run *store.Run, storeRoot string, forgotten func(host str // novelHostCandidates are the hosts a novelty baseline should consider: what // this session named, whether over https or ssh, minus loopback and minus -// the client's own plane. Folding the client plane into a baseline would make +// the client's own plane. Loopback is listed among the declared hosts and +// never counted as novel: a local server is not a new place the project's +// traffic goes, first baseline entry or not. It is decided on the parsed +// address (host.Loopback), so [0:0:0:0:0:0:0:1], 127.0.0.2 and api.localhost +// are loopback as [::1] is. Folding the client plane into a baseline would make // api.anthropic.com a "new destination for this project" on the first session // of every project, which is the novelty line's most obvious way to make // itself worthless. func novelHostCandidates(hosts []string, mcpAttributed bool) []string { var out []string for _, h := range hosts { - if loopbackHosts[h] || clientPlaneHost(h, mcpAttributed) { + if host.Loopback(h) || clientPlaneHost(h, mcpAttributed) { continue } out = append(out, h) diff --git a/internal/report/rubric_lld4_test.go b/internal/report/rubric_lld4_test.go new file mode 100644 index 00000000..2aaabb3b --- /dev/null +++ b/internal/report/rubric_lld4_test.go @@ -0,0 +1,22 @@ +package report + +import ( + "reflect" + "testing" +) + +// TestRubricLLD4_LoopbackSpellingsAreNotNovel: a local server is not a new +// place a project's traffic goes, in whatever spelling the call named it. +// Loopback was a list of five spellings, so [0:0:0:0:0:0:0:1], 127.0.0.2, +// 2130706433 and api.localhost were offered to the baseline as candidates +// for "new destination" while [::1] and 127.0.0.1 were not. +func TestRubricLLD4_LoopbackSpellingsAreNotNovel(t *testing.T) { + got := novelHostCandidates([]string{ + "localhost", "127.0.0.1", "[::1]", "0.0.0.0", + "[0:0:0:0:0:0:0:1]", "127.0.0.2", "2130706433", "api.localhost", "[::]", + "api.anthropic.com", "example.com", "10.1.2.3", + }, false) + if want := []string{"example.com", "10.1.2.3"}; !reflect.DeepEqual(got, want) { + t.Errorf("novelty candidates %q, want %q", got, want) + } +} diff --git a/internal/shape/hosts.go b/internal/shape/hosts.go index 6e98dc10..03b6c6af 100644 --- a/internal/shape/hosts.go +++ b/internal/shape/hosts.go @@ -360,7 +360,11 @@ func destinationHost(tok string, requireRemote bool) (string, bool) { if remoteByColon && !hadUser && len(hostPart) < 2 { return "", false } - if !hostnameShaped(hostPart) { + // The token IS the destination here, so the question is not where the + // host ends but whether it is a host at all, and only a whitelist + // answers that: `ssh HOST=bad` recorded "host=bad" and `ssh + // h.example.com!x` recorded itself before it (host.Shaped). + if !host.Shaped(hostPart) { return "", false } // An ssh-config alias has no dots and resolves to something this function @@ -371,45 +375,6 @@ func destinationHost(tok string, requireRemote bool) (string, bool) { return host.Canonical(hostPart) } -// hostnameShaped reports whether every byte of s could appear in a hostname. -// -// A whitelist, not a terminator set, and that is the point. authority() stops -// at the punctuation a URL is surrounded by in a command line, which is right -// where a host is embedded in text; it does not stop at '=' or '!', so -// `ssh HOST=bad` recorded "host=bad" and `ssh h.example.com!x` recorded itself -// -- raw command text passing as a canonical hostname. Here the token IS the -// destination, so the question is not "where does the host end" but "is this a -// host at all", and only a whitelist answers that one. -// -// The length bound is the longest a hostname can be. Without it a -// several-hundred byte fragment of a command line can be persisted as one. -func hostnameShaped(s string) bool { - if s == "" || len(s) > 253 { - return false - } - if s[0] == '-' || s[0] == '.' || s[len(s)-1] == '-' { - return false - } - // An empty label is not a hostname. One TRAILING dot is: "pypi.org." is - // the fully-qualified spelling of the same name, and host.Canonical drops - // it. Two in a row are never right. - if strings.Contains(s, "..") { - return false - } - alnum := false - for i := 0; i < len(s); i++ { - c := s[i] - switch { - case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': - alnum = true - case c == '-' || c == '.' || c == '_': - default: - return false - } - } - return alnum -} - // collect finds every hostname in text introduced by one of the given prefixes. // // The scan and the slice both read `lower`, and they have to be the same @@ -425,6 +390,12 @@ func hostnameShaped(s string) bool { // // Reading the lowered copy costs nothing: host.Canonical lower-cases what it // returns, and DNS is case-insensitive, so the original case was never wanted. +// +// What authority cuts is a host only if host.Shaped says so. authority stops +// at the punctuation a URL is surrounded by in a command line, which is not +// where a host ends: `=` and `!` are neither, so `https://api.example.com= +// ghp_...!x/` came back as the host "api.example.com=ghp_...!x", a token +// recorded in the field that holds the hostnames a call named. func collect(text string, prefixes []string) []string { seen := map[string]struct{}{} lower := strings.ToLower(text) @@ -438,7 +409,7 @@ func collect(text string, prefixes []string) []string { } at := from + i + len(p) from = at - if h, ok := host.Canonical(authority(lower[at:])); ok { + if h, ok := host.Canonical(authority(lower[at:])); ok && host.Shaped(h) { seen[h] = struct{}{} } } diff --git a/internal/shape/hosts_test.go b/internal/shape/hosts_test.go index f696b29b..4b972899 100644 --- a/internal/shape/hosts_test.go +++ b/internal/shape/hosts_test.go @@ -514,3 +514,44 @@ func TestSSHHostsAreSortedAndDeduped(t *testing.T) { } } } + +// TestRubricLLD2_URLHostsAreHostnameShaped: a URL's host is cut out of a +// command line at the line's punctuation, and what is cut has to be a host +// before it is recorded as one. `=` and `!` end neither a URL nor a host, so +// without the shape check `curl https://api.example.com=ghp_...!x/` recorded +// the host "api.example.com=ghp_q4br7xyzsecrettok3n!x", and an authority of +// 300,000 bytes was recorded whole. A name of 253 bytes is a host; 254 is not. +func TestRubricLLD2_URLHostsAreHostnameShaped(t *testing.T) { + label := strings.Repeat("a", 63) + long := func(n int) string { + var b strings.Builder + for b.Len() < n { + if b.Len() > 0 { + b.WriteByte('.') + } + b.WriteString(label[:min(63, n-b.Len())]) + } + return b.String() + } + for _, tc := range []struct { + cmd string + want []string + }{ + {"curl https://api.example.com=ghp_q4br7xyzsecrettok3n!x/", nil}, + {"curl https://h.example.com!x/path", nil}, + {"curl https://ok.example.com/a https://bad.example.com=x/", []string{"ok.example.com"}}, + {"curl https://" + strings.Repeat("x", 300_000) + "/", nil}, + {"curl https://" + long(253) + "/", []string{long(253)}}, + {"curl https://" + long(254) + "/", nil}, + {"curl https://bücher.example/", []string{"bücher.example"}}, + {"curl 'https://[2606:4700:0:0:0:0:0:1111]:443/'", []string{"[2606:4700::1111]"}}, + {"curl https://167838211/", []string{"10.1.2.3"}}, + } { + if got, _ := Hosts("Bash", bashInput(t, tc.cmd)); !reflect.DeepEqual(got, tc.want) { + t.Errorf("hosts of %.80q = %.80q, want %q", tc.cmd, got, tc.want) + } + } + if got, _ := Hosts("WebFetch", json.RawMessage(`{"url":"https://api.example.com=tok!x/"}`)); got != nil { + t.Errorf("WebFetch hosts = %q, want none", got) + } +} diff --git a/test/mutation/mutants/H-70_hosts.py b/test/mutation/mutants/H-70_hosts.py index 42530fb4..8d6c8a6e 100644 --- a/test/mutation/mutants/H-70_hosts.py +++ b/test/mutation/mutants/H-70_hosts.py @@ -23,7 +23,7 @@ "\tsort.Strings(out)\n\treturn out\n}\n\n// sshDestPrograms", "\treturn out\n}\n\n// sshDestPrograms", "TestSSHHostsAreSortedAndDeduped") m("H-70 the destination is not required to be hostname shaped", "internal/shape/hosts.go", - "\tif !hostnameShaped(hostPart) {\n\t\treturn \"\", false\n\t}", + "\tif !host.Shaped(hostPart) {\n\t\treturn \"\", false\n\t}", "\tif hostPart == \"\" {\n\t\treturn \"\", false\n\t}", "TestH70_CommandText|TestSSHDestinationIsHostnameShaped|TestSSHDestinationRefusesWhatIsNotAHost") m("H-70 one flag table for all four programs", "internal/shape/hosts.go", diff --git a/test/mutation/mutants/NONO_nono.py b/test/mutation/mutants/NONO_nono.py index 87dab82a..99c8bd5e 100644 --- a/test/mutation/mutants/NONO_nono.py +++ b/test/mutation/mutants/NONO_nono.py @@ -200,9 +200,10 @@ # The client-plane rule has one spelling, clientPlaneHost, and every view # that asks it of this session calls it (review of #53): the novelty # candidates used to restate it. Re-anchored when the proxy era left: the -# candidates are the declared hosts, a list of names. +# candidates are the declared hosts, a list of names. Re-anchored again when +# loopback came to be decided on the parsed address (host.Loopback). m("NONO the novelty candidates drop the MCP transport on an MCP session", "internal/report/destinations.go", - "\t\tif loopbackHosts[h] || clientPlaneHost(h, mcpAttributed) {", "\t\tif loopbackHosts[h] || clientPlaneHosts[h] {", + "\t\tif host.Loopback(h) || clientPlaneHost(h, mcpAttributed) {", "\t\tif host.Loopback(h) || clientPlaneHosts[h] {", "TestClientPlane_EveryView") m("NONO the session's MCP calls never reach the novelty candidates", "internal/report/destinations.go", "madeMCPCall(run)))", "false))", diff --git a/test/mutation/mutants/RUBRIC_lld-2_host-shape.py b/test/mutation/mutants/RUBRIC_lld-2_host-shape.py new file mode 100644 index 00000000..57fff608 --- /dev/null +++ b/test/mutation/mutants/RUBRIC_lld-2_host-shape.py @@ -0,0 +1,14 @@ +# rubric lld-2 (fix list 10): a host cut out of a command line is recorded +# only if it has the shape of a host, and no host is longer than 253 bytes. +# Recording what authority cut without asking, letting `=` or `!` pass, or +# dropping either length bound is the regression. +m("RUBRIC lld-2 a URL's host is recorded without the shape check", "internal/shape/hosts.go", + "ok && host.Shaped(h) {", "ok {", "TestRubricLLD2_") +m("RUBRIC lld-2 Canonical has no length bound", "internal/host/host.go", + "\tif h == \"\" || h == \".\" || len(h) > maxName {", "\tif h == \"\" || h == \".\" {", "TestRubricLLD2_") +m("RUBRIC lld-2 Shaped has no length bound", "internal/host/host.go", + "\tif s == \"\" || len(s) > maxName || !utf8.ValidString(s) {", "\tif s == \"\" || !utf8.ValidString(s) {", "TestRubricLLD2_") +m("RUBRIC lld-2 the bound is one byte long", "internal/host/host.go", + "const maxName = 253\n", "const maxName = 254\n", "TestRubricLLD2_") +m("RUBRIC lld-2 = and ! are host bytes", "internal/host/host.go", + "\t\tcase r == '-' || r == '.' || r == '_':\n", "\t\tcase r == '-' || r == '.' || r == '_' || r == '=' || r == '!':\n", "TestRubricLLD2_") diff --git a/test/mutation/mutants/RUBRIC_lld-4_spellings.py b/test/mutation/mutants/RUBRIC_lld-4_spellings.py new file mode 100644 index 00000000..c6a0dd9f --- /dev/null +++ b/test/mutation/mutants/RUBRIC_lld-4_spellings.py @@ -0,0 +1,26 @@ +# rubric lld-4 (fix list 30): every spelling of one IP address canonicalises +# to one string, and loopback is decided on the parsed address. Keeping an +# address as written, leaving an IPv4-mapped address mapped, skipping the +# inet_aton forms or misreading their parts, or deciding loopback from a list +# of spellings is the regression. +m("RUBRIC lld-4 an IPv6 literal is kept as written", "internal/host/host.go", + "\t\treturn spell(a), true\n", "\t\treturn h, true\n", "TestRubricLLD4_") +m("RUBRIC lld-4 an IPv4-mapped address stays mapped", "internal/host/host.go", + "\tif a = a.Unmap(); a.Is4() {", "\tif a.Is4() {", "TestRubricLLD4_") +m("RUBRIC lld-4 the inet_aton forms are names", "internal/host/host.go", + "\tif a, ok := inetAton(h); ok {", "\tif a, ok := inetAton(h); ok && false {", "TestRubricLLD4_") +m("RUBRIC lld-4 the last inet_aton part fills one byte", "internal/host/host.go", + "\t\t\twidth = 8 * uint(5-len(parts))\n", "\t\t\twidth = 8\n", "TestRubricLLD4_") +m("RUBRIC lld-4 a leading 0 is read as decimal", "internal/host/host.go", + "\t\tp, base = p[1:], 8\n", "\t\tp, base = p[1:], 10\n", "TestRubricLLD4_") +m("RUBRIC lld-4 loopback is a list of spellings", "internal/host/host.go", + "\treturn err == nil && (a.IsLoopback() || a.IsUnspecified())\n", + "\treturn err == nil && (a.String() == \"127.0.0.1\" || a.String() == \"::1\" || a.IsUnspecified())\n", "TestRubricLLD4_") +m("RUBRIC lld-4 a name under .localhost is not loopback", "internal/host/host.go", + "\tif c == \"localhost\" || strings.HasSuffix(c, \".localhost\") {", "\tif c == \"localhost\" {", "TestRubricLLD4_") +m("RUBRIC lld-4 the report filters loopback by its old spellings", "internal/report/destinations.go", + "\t\tif host.Loopback(h) || clientPlaneHost(h, mcpAttributed) {", + "\t\tif h == \"localhost\" || h == \"127.0.0.1\" || h == \"[::1]\" || h == \"0.0.0.0\" || clientPlaneHost(h, mcpAttributed) {", + "TestRubricLLD4_") +IMPORTS["RUBRIC lld-4 the report filters loopback by its old spellings"] = ("internal/report/destinations.go", + "\t\"github.com/altrace-dev-role/rashomon/internal/host\"\n", "") From 14ff36e571ed07f58813157c413ae2fb045ea63f Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 11:19:55 -0700 Subject: [PATCH 06/50] baseline: lock the load-fold-save, cap the host map, write only what changed Rendering a report did an unlocked load-modify-save of the project baseline, and `forget --host` rewrote the same file with no lock either. Two concurrent renders each loaded the file, folded their session in and renamed their copy into place, and the second rename threw away the first's host: 200 of 200 rounds of the review's probe, and round 0 of the new test here. A later session was then told a host was new that an earlier one had named, and a forget and a render could each undo the other's change. The decision is now a pure Fold(file, existed, session, start, hosts), which returns the new file, the result and whether anything changed, and Update is a locked load, Fold and save around it. The lock is a sidecar file, baseline/.lock, because the save renames over the data file and a lock on the replaced inode would guard nothing. It is the store's own bounded flock (store.LockPath, a thin export of lockFile with the 2 s budget), so the timeout behaviour is the store's; a lock that cannot be had renders novelty as unknown. ForgetFile takes the same lock. The host map had no cap and every render rewrote it whole. It now holds at most maxBaselineHosts = 4,096 hosts (RES-11's figure), with the reasoning beside the constant. Each entry gains last_seen_at_unix_ms (additive, omitted when zero, version unchanged), and past the cap the least recently seen hosts go first, never one the folding session named; the count is on Result.Evicted. A session that alone names more than the cap cannot be folded without dropping its own hosts, so it reports ErrTooManyHosts (novelty unknown) and writes nothing. That settles the policy difference between the rubric (report unknown at the cap) and the spec (evict the least recently seen): evict where eviction can be right, unknown where it cannot. A render that changes nothing, such as a second render of one session, no longer rewrites the file. A baseline file is read through a bound, maxBaselineBytes = 4,096 entries of at most 1 KiB plus 64 KiB of header, and a larger one is read as corrupt (novelty unknown) instead of read whole; a save that would write a larger one is refused. The spec's 256 KiB per file does not hold beside its 4,096 hosts: an entry takes about 150 bytes as written, so 4,096 take about 600 KiB, and at 256 KiB the effective cap would be about 1,700 hosts. The TRU-58 acceptance helper that found "the one project's file" under baseline/ now counts only *.json files, since the lock file sits beside them. Tests: TestRubricHLD2_ConcurrentRendersLoseNoHost and TestRubricHLD2_ForgetAndRenderLoseNoChange (40 rounds each, through internal/safe goroutines; both failed at round 0 before the lock), TestRubricHLD2_FoldIsPure, TestRES11_EveryCapAtNAndNPlusOne (the baseline's row: 4,096 and 4,097 hosts, the least recently seen going, an old session's own host kept, and 4,097 and 4,096 hosts named by one session), TestRubricLLD10_AnUnchangedRenderWritesNothing, TestRubricLLD10_AnOversizedFileIsUnknown (exactly the bound is read, one byte more is not). Mutants: RUBRIC_hld-2_baseline-lock.py (three), RES-11_baseline-hosts.py (five, including the spec's "remove the 4,096-host check"), RUBRIC_lld-10_baseline-writes.py (three). Not done: the spec's 128-file cap on baseline/ (least recently written project file removed) and a health line that shows evictions. Rubric fix list entries 28 (hld-2) and 53 (lld-10), RES-11's baseline row. CWE-362, CWE-400. --- internal/baseline/baseline.go | 152 +++++------ internal/baseline/fold.go | 190 +++++++++++++ internal/baseline/lock_cap_test.go | 250 ++++++++++++++++++ internal/store/lockpath.go | 28 ++ test/acceptance/tru58_baseline_test.go | 13 +- .../mutation/mutants/RES-11_baseline-hosts.py | 14 + .../mutants/RUBRIC_hld-2_baseline-lock.py | 13 + .../mutants/RUBRIC_lld-10_baseline-writes.py | 9 + 8 files changed, 587 insertions(+), 82 deletions(-) create mode 100644 internal/baseline/fold.go create mode 100644 internal/baseline/lock_cap_test.go create mode 100644 internal/store/lockpath.go create mode 100644 test/mutation/mutants/RES-11_baseline-hosts.py create mode 100644 test/mutation/mutants/RUBRIC_hld-2_baseline-lock.py create mode 100644 test/mutation/mutants/RUBRIC_lld-10_baseline-writes.py diff --git a/internal/baseline/baseline.go b/internal/baseline/baseline.go index 7cae1893..cee1bff2 100644 --- a/internal/baseline/baseline.go +++ b/internal/baseline/baseline.go @@ -25,11 +25,11 @@ import ( "io/fs" "os" "path/filepath" - "sort" "strings" "time" "github.com/altrace-dev-role/rashomon/internal/fault" + "github.com/altrace-dev-role/rashomon/internal/store" ) // Version is carried by the file so a later reader can refuse a shape it does @@ -69,10 +69,15 @@ var ubiquitous = map[string]bool{ // report can say so rather than silently dropping the host. func Ubiquitous(host string) bool { return ubiquitous[host] } -// entry is when a host was first named in this project, and by which session. +// entry is when a host was first named in this project, and by which session, +// and when it was last named: the start of the latest session that named it, +// which decides which host goes first at the cap (evict). Zero on an entry +// written before the field existed, which is then read as last seen when it +// was first seen. type entry struct { FirstSeenSession string `json:"first_seen_session"` FirstSeenAtUnixMS int64 `json:"first_seen_at_unix_ms"` + LastSeenAtUnixMS int64 `json:"last_seen_at_unix_ms,omitempty"` } // File is one project's baseline. @@ -129,6 +134,9 @@ type Result struct { // ("N known"). It grows as other sessions run; what does not change is which // hosts this session owns. Known int + // Evicted is how many hosts this fold dropped at the cap, least recently + // seen first (maxBaselineHosts). + Evicted int // Path is the file consulted, for the report to name when it could not be // read. Path string @@ -206,78 +214,47 @@ func fileName(key string) string { // root is the store root; the baseline lives in a sibling directory of runs/ so // that eviction of runs cannot touch it. sessionStart is the session's own // start instant, which is what decides ownership. +// +// The load, the fold and the save happen under one lock (lockName), which +// `forget --host` takes too. Without it two renders each loaded the file, +// folded and renamed their own copy into place, and the second threw away the +// first's hosts -- in 200 of 200 rounds of a probe -- so a later session was +// told a host was new that an earlier one had named; and a forget and a +// render could each undo the other. A render that learns nothing writes +// nothing. func Update(root, key, sessionID string, sessionStart time.Time, hosts []string) Result { - res := Result{} if root == "" || key == "" { - res.Err = errors.New("baseline: no store root or project key") - return res + return Result{Err: errors.New("baseline: no store root or project key")} } dir := filepath.Join(root, "baseline") - res.Path = filepath.Join(dir, fileName(key)) - - f, existed, err := load(res.Path, key) + path := filepath.Join(dir, fileName(key)) + res, err := update(dir, path, key, sessionID, sessionStart, hosts) + res.Path = path if err != nil { res.Err = err - return res - } - stamp := sessionStart.UTC().UnixMilli() - switch { - case !existed: - f.EstablishedBySession = sessionID - f.EstablishedAtUnixMS = stamp - case f.EstablishedBySession != "" && stamp > 0 && - (f.EstablishedAtUnixMS == 0 || stamp < f.EstablishedAtUnixMS): - // An earlier session than the recorded establisher. It was the project's - // first, so it takes the slot -- the same earliest-wins correction the - // per-host entries make, for the same reason: the answer must be a - // property of the sessions and not of the order someone read them in. - // Monotone, so it converges rather than oscillating. - // - // Guarded on a recorded establisher EXISTING. A file written before this - // field names nobody, and "nobody established it" must not become "the - // next session to render claims it" -- that would hand the slot, and the - // suppression of every novelty finding that comes with it, to whoever - // happened to run report first on an upgraded install. - f.EstablishedBySession = sessionID - f.EstablishedAtUnixMS = stamp - } - res.Established = f.EstablishedBySession != "" && f.EstablishedBySession == sessionID - - for _, h := range hosts { - if h == "" { - continue - } - prev, seen := f.Hosts[h] - switch { - case !seen: - f.Hosts[h] = entry{FirstSeenSession: sessionID, FirstSeenAtUnixMS: stamp} - case stamp > 0 && (prev.FirstSeenAtUnixMS == 0 || stamp < prev.FirstSeenAtUnixMS): - // An earlier session is being rendered after a later one. The - // earlier one owns the host; correcting toward it is what keeps a - // session's novel set a property of the session rather than of the - // order somebody read the reports in. - f.Hosts[h] = entry{FirstSeenSession: sessionID, FirstSeenAtUnixMS: stamp} - } } - res.Known = len(f.Hosts) + return res +} - // Novel is computed from the file AFTER folding, so it is exactly "the - // hosts this session owns" -- stable across renders by construction. - for _, h := range hosts { - if ubiquitous[h] { - continue - } - if e, ok := f.Hosts[h]; ok && e.FirstSeenSession == sessionID && !res.Established { - res.Novel = append(res.Novel, h) - } +// update is Update's locked load-fold-save. +func update(dir, path, key, sessionID string, sessionStart time.Time, hosts []string) (Result, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return Result{}, err } - sort.Strings(res.Novel) - res.Novel = dedupe(res.Novel) - - if err := save(dir, res.Path, f); err != nil { - res.Err = err + release, err := store.LockPath(filepath.Join(dir, lockName)) + if err != nil { + return Result{}, err } - return res + defer release() + f, existed, err := load(path, key) + if err != nil { + return Result{}, err + } + next, res, changed := Fold(f, existed, sessionID, sessionStart, hosts) + if res.Err != nil || !changed { + return res, nil + } + return res, save(dir, path, next) } // load reads a project's baseline. existed is false for a file that is not @@ -310,13 +287,23 @@ func load(path, key string) (File, bool, error) { // parse reads one baseline file as written, whatever its basis says. func parse(path string) (File, error) { - body, err := os.ReadFile(path) + body, err := readFile(path) if err != nil { return File{}, err } return decode(body) } +// readFile reads a baseline file whole, refusing one over maxBaselineBytes. +func readFile(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() //nolint:errcheck // read-only + return readBounded(f) +} + // decode reads a baseline's bytes, refusing a version this build does not // know rather than guessing at its shape. func decode(body []byte) (File, error) { @@ -346,6 +333,9 @@ func save(dir, path string, f File) error { return err } body = append(body, '\n') + if len(body) > maxBaselineBytes { + return errTooLarge + } tmp, err := os.CreateTemp(dir, ".baseline-*") if err != nil { @@ -385,19 +375,6 @@ func digest(key string) string { return hex.EncodeToString(sum[:])[:12] } -func dedupe(xs []string) []string { - if len(xs) < 2 { - return xs - } - out := xs[:1] - for _, x := range xs[1:] { - if x != out[len(out)-1] { - out = append(out, x) - } - } - return out -} - // ForgetFile removes a host from one baseline file by path, returning 1 when // the file held it and 0 when it did not. A file this build will not rewrite // -- one without the basis marker, or one it cannot parse whose bytes hold @@ -407,11 +384,28 @@ func dedupe(xs []string) []string { // project the caller meant and must clear the host from every baseline that // remembers it. That is the conservative direction: a host the user asked to // forget must not survive in a project they did not think to name. +// +// It takes the lock Update takes, so a forget and a render cannot each save a +// copy loaded before the other's change. func ForgetFile(path, host string) (int, error) { if path == "" || host == "" { return 0, nil } - body, err := os.ReadFile(path) + release, err := store.LockPath(filepath.Join(filepath.Dir(path), lockName)) + if errors.Is(err, fs.ErrNotExist) { + // No baseline directory: nothing to clear. + return 0, nil + } + if err != nil { + return 0, err + } + defer release() + return forgetLocked(path, host) +} + +// forgetLocked is ForgetFile under the lock. +func forgetLocked(path, host string) (int, error) { + body, err := readFile(path) if errors.Is(err, fs.ErrNotExist) { // Listed, then gone before it was read: nothing left to clear. return 0, nil diff --git a/internal/baseline/fold.go b/internal/baseline/fold.go new file mode 100644 index 00000000..65db9837 --- /dev/null +++ b/internal/baseline/fold.go @@ -0,0 +1,190 @@ +package baseline + +import ( + "errors" + "io" + "maps" + "sort" + "time" +) + +// lockName is the lock file beside the baselines, taken around every +// load-modify-save of one (store.LockPath). One lock for the directory, not +// one per file: renders and forgets are rare, and a forget walks every file. +const lockName = ".lock" + +// maxBaselineHosts caps one project's baseline (RES-11; spec archive 85.3.7). +// A project names tens to a few hundred distinct hosts over its life, so the +// cap is reached only by a project that names hosts without end -- generated +// names, or a crawl -- and there the file would otherwise grow with every +// render, which reads and rewrites it whole. Past the cap the least recently +// seen hosts go (evict): a host not named for the longest time is the one +// whose return is least misleading to call new. +const maxBaselineHosts = 4096 + +// maxEntryBytes bounds one host's entry as save writes it: a 253-byte name, +// a session id of up to 256 bytes (RECORD's id cap), two 13-digit stamps, +// and the keys and indentation, about 140 bytes -- 1,024 with room for JSON +// escaping. maxBaselineBytes is the file at the cap of such entries, plus +// 64 KiB for the header and its project key, a path. A file larger than that +// is not one this package wrote, and is read as corrupt rather than read +// whole; a save that would write one is refused rather than leaving a file +// the next render cannot read. +// +// The spec's 256 KiB per file (archive 85.3.7) does not hold beside its +// 4,096 hosts: an entry takes about 150 bytes as written, so 4,096 of them +// take about 600 KiB, and at 256 KiB the effective cap would be about 1,700. +const ( + maxEntryBytes = 1 << 10 + maxBaselineBytes = maxBaselineHosts*maxEntryBytes + 64<<10 +) + +// ErrTooManyHosts is Update's answer for a session that alone names more +// hosts than the cap: it cannot be folded without dropping hosts it named, +// so its novelty is unknown rather than wrong, and nothing is written. +var ErrTooManyHosts = errors.New("baseline: the session names more hosts than a baseline holds") + +// errTooLarge refuses a baseline file over maxBaselineBytes, read or written. +var errTooLarge = errors.New("baseline: file larger than a baseline can be") + +// Fold folds one session's hosts into a project's baseline f and reports +// which were new. It is pure: f is not modified, and the file it returns is +// the one Update saves when changed is true. existed is false for a project +// with no baseline yet, which this session then establishes. +// +// Ownership is earliest-session-wins (see the package note): a host goes to +// the earliest session that named it, whichever order sessions are folded +// in. Each host also records the latest session that named it, which decides +// eviction at the cap. +func Fold(f File, existed bool, sessionID string, sessionStart time.Time, hosts []string) (File, Result, bool) { + stamp := sessionStart.UTC().UnixMilli() + out := f + out.Hosts = maps.Clone(f.Hosts) + if out.Hosts == nil { + out.Hosts = map[string]entry{} + } + changed := establish(&out, existed, sessionID, stamp) + res := Result{Established: out.EstablishedBySession != "" && out.EstablishedBySession == sessionID} + + named := map[string]bool{} + for _, h := range hosts { + if h != "" { + named[h] = true + } + } + if len(named) > maxBaselineHosts { + res.Err = ErrTooManyHosts + return f, res, false + } + for h := range named { + prev, seen := out.Hosts[h] + next := prev + switch { + case !seen: + next = entry{FirstSeenSession: sessionID, FirstSeenAtUnixMS: stamp} + case stamp > 0 && (prev.FirstSeenAtUnixMS == 0 || stamp < prev.FirstSeenAtUnixMS): + // An earlier session is being rendered after a later one. The + // earlier one owns the host; correcting toward it is what keeps a + // session's novel set a property of the session rather than of the + // order somebody read the reports in. + next.FirstSeenSession, next.FirstSeenAtUnixMS = sessionID, stamp + } + next.LastSeenAtUnixMS = max(next.LastSeenAtUnixMS, stamp) + if !seen || next != prev { + out.Hosts[h] = next + changed = true + } + } + res.Evicted = evict(out.Hosts, named) + changed = changed || res.Evicted > 0 + res.Known = len(out.Hosts) + + // Novel is computed from the file AFTER folding, so it is exactly "the + // hosts this session owns" -- stable across renders by construction. + for h := range named { + if e := out.Hosts[h]; !ubiquitous[h] && e.FirstSeenSession == sessionID && !res.Established { + res.Novel = append(res.Novel, h) + } + } + sort.Strings(res.Novel) + return out, res, changed +} + +// establish records the session that established f, when it is the first or +// an earlier one than the recorded establisher, and reports whether f +// changed. +func establish(f *File, existed bool, sessionID string, stamp int64) bool { + switch { + case !existed: + f.EstablishedBySession, f.EstablishedAtUnixMS = sessionID, stamp + return true + case f.EstablishedBySession != "" && stamp > 0 && + (f.EstablishedAtUnixMS == 0 || stamp < f.EstablishedAtUnixMS): + // An earlier session than the recorded establisher. It was the project's + // first, so it takes the slot -- the same earliest-wins correction the + // per-host entries make, for the same reason: the answer must be a + // property of the sessions and not of the order someone read them in. + // Monotone, so it converges rather than oscillating. + // + // Guarded on a recorded establisher EXISTING. A file written before this + // field names nobody, and "nobody established it" must not become "the + // next session to render claims it" -- that would hand the slot, and the + // suppression of every novelty finding that comes with it, to whoever + // happened to run report first on an upgraded install. + f.EstablishedBySession, f.EstablishedAtUnixMS = sessionID, stamp + return true + } + return false +} + +// evict removes the least recently seen hosts past maxBaselineHosts, never +// one in keep -- the hosts the folding session named, which are what it +// reports on -- and returns how many it removed. Ties go by name, so two +// folds of the same file evict the same hosts. +func evict(hosts map[string]entry, keep map[string]bool) int { + over := len(hosts) - maxBaselineHosts + if over <= 0 { + return 0 + } + cands := make([]string, 0, len(hosts)) + for h := range hosts { + if !keep[h] { + cands = append(cands, h) + } + } + sort.Slice(cands, func(i, j int) bool { + a, b := lastSeen(hosts[cands[i]]), lastSeen(hosts[cands[j]]) + if a != b { + return a < b + } + return cands[i] < cands[j] + }) + // keep never holds more than the cap (Fold refuses such a session), so + // there are always enough candidates; min keeps that a fact here too. + over = min(over, len(cands)) + for _, h := range cands[:over] { + delete(hosts, h) + } + return over +} + +// lastSeen is when a host was last named: its first sighting on an entry +// written before the field existed. +func lastSeen(e entry) int64 { + if e.LastSeenAtUnixMS > 0 { + return e.LastSeenAtUnixMS + } + return e.FirstSeenAtUnixMS +} + +// readBounded reads a baseline file, refusing one over maxBaselineBytes. +func readBounded(r io.Reader) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(r, maxBaselineBytes+1)) + if err != nil { + return nil, err + } + if len(body) > maxBaselineBytes { + return nil, errTooLarge + } + return body, nil +} diff --git a/internal/baseline/lock_cap_test.go b/internal/baseline/lock_cap_test.go new file mode 100644 index 00000000..ab7ed5e8 --- /dev/null +++ b/internal/baseline/lock_cap_test.go @@ -0,0 +1,250 @@ +package baseline + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/altrace-dev-role/rashomon/internal/safe" +) + +// both runs a and b at once and waits for both. +func both(t *testing.T, a, b func()) { + t.Helper() + start := make(chan struct{}) + fail := func(err error) { t.Error(err) } + da := safe.Go(func() { <-start; a() }, fail) + db := safe.Go(func() { <-start; b() }, fail) + close(start) + <-da + <-db +} + +// onDisk is the host set of the project file under root, as saved. +func onDisk(t *testing.T, root, key string) map[string]entry { + t.Helper() + f, err := parse(filepath.Join(root, "baseline", fileName(key))) + if err != nil { + t.Fatal(err) + } + return f.Hosts +} + +// TestRubricHLD2_ConcurrentRendersLoseNoHost: two renders of two sessions in +// one project fold their hosts into one file. Each loaded the file, folded +// and renamed its copy into place with no lock, so the second rename threw +// away the first render's host -- in 200 of 200 rounds of the probe -- and a +// later session was told that host was new. +func TestRubricHLD2_ConcurrentRendersLoseNoHost(t *testing.T) { + for round := 0; round < 40; round++ { + root, key := t.TempDir(), "/proj" + seed := Update(root, key, "s0", t0, []string{"seed.example"}) + if seed.Err != nil { + t.Fatal(seed.Err) + } + var ra, rb Result + both(t, + func() { ra = Update(root, key, "s1", t0.Add(time.Hour), []string{"one.example"}) }, + func() { rb = Update(root, key, "s2", t0.Add(2*time.Hour), []string{"two.example"}) }) + if ra.Err != nil || rb.Err != nil { + t.Fatalf("round %d: %v, %v", round, ra.Err, rb.Err) + } + hosts := onDisk(t, root, key) + for _, h := range []string{"seed.example", "one.example", "two.example"} { + if _, ok := hosts[h]; !ok { + t.Fatalf("round %d: %s lost; the file holds %v", round, h, keys(hosts)) + } + } + } +} + +// TestRubricHLD2_ForgetAndRenderLoseNoChange: `forget --host` rewrites the +// same file a render does, and each could undo the other: the render saving +// a copy loaded before the forget put the forgotten host back, and the +// forget saving a copy loaded before the render dropped the render's host. +func TestRubricHLD2_ForgetAndRenderLoseNoChange(t *testing.T) { + for round := 0; round < 40; round++ { + root, key := t.TempDir(), "/proj" + if r := Update(root, key, "s0", t0, []string{"gone.example", "kept.example"}); r.Err != nil { + t.Fatal(r.Err) + } + var r Result + var n int + var ferr error + both(t, + func() { r = Update(root, key, "s1", t0.Add(time.Hour), []string{"new.example"}) }, + func() { n, ferr = ForgetFile(filepath.Join(root, "baseline", fileName(key)), "gone.example") }) + if r.Err != nil || ferr != nil || n != 1 { + t.Fatalf("round %d: render %v, forget %d, %v", round, r.Err, n, ferr) + } + hosts := onDisk(t, root, key) + if _, ok := hosts["gone.example"]; ok { + t.Fatalf("round %d: the forgotten host came back: %v", round, keys(hosts)) + } + for _, h := range []string{"kept.example", "new.example"} { + if _, ok := hosts[h]; !ok { + t.Fatalf("round %d: %s lost: %v", round, h, keys(hosts)) + } + } + } +} + +// TestRubricHLD2_FoldIsPure: Fold is the whole decision and touches nothing +// it was handed, so the locked load-fold-save around it is the only place a +// file changes. +func TestRubricHLD2_FoldIsPure(t *testing.T) { + in := File{Version: Version, Project: "/p", Basis: Basis, EstablishedBySession: "s0", EstablishedAtUnixMS: 1, + Hosts: map[string]entry{"a.example": {FirstSeenSession: "s0", FirstSeenAtUnixMS: 1, LastSeenAtUnixMS: 1}}} + before := fmt.Sprint(in.Hosts) + out, res, changed := Fold(in, true, "s1", time.UnixMilli(5), []string{"a.example", "b.example"}) + if fmt.Sprint(in.Hosts) != before { + t.Fatalf("Fold changed the file it was handed: %v, was %v", in.Hosts, before) + } + if !changed || len(out.Hosts) != 2 || !reflect.DeepEqual(res.Novel, []string{"b.example"}) { + t.Fatalf("changed %v, hosts %v, novel %v", changed, keys(out.Hosts), res.Novel) + } + if _, _, changed := Fold(out, true, "s1", time.UnixMilli(5), []string{"a.example", "b.example"}); changed { + t.Fatalf("folding the same session again changed the file") + } +} + +// fileWith is an established baseline of n hosts h0..h(n-1), host i last +// seen at i+1. +func fileWith(n int) File { + f := File{Version: Version, Project: "/p", Basis: Basis, EstablishedBySession: "s0", EstablishedAtUnixMS: 1, + Hosts: make(map[string]entry, n)} + for i := 0; i < n; i++ { + f.Hosts[fmt.Sprintf("h%d.example", i)] = entry{FirstSeenSession: "s0", FirstSeenAtUnixMS: 1, LastSeenAtUnixMS: int64(i + 1)} + } + return f +} + +// TestRES11_EveryCapAtNAndNPlusOne (the baseline's row): a project baseline +// holds at most maxBaselineHosts hosts. At N nothing goes; at N+1 the least +// recently seen host goes, never one the folding session named, and the +// eviction is counted. A session that alone names more than the cap cannot +// be folded without dropping its own hosts, so its novelty is unknown and +// nothing is written. +func TestRES11_EveryCapAtNAndNPlusOne(t *testing.T) { + if maxBaselineHosts != 4096 { + t.Fatalf("maxBaselineHosts = %d, want 4096", maxBaselineHosts) + } + at, res, _ := Fold(fileWith(maxBaselineHosts-1), true, "s1", time.UnixMilli(1<<40), []string{"new.example"}) + if res.Err != nil || len(at.Hosts) != maxBaselineHosts || res.Evicted != 0 { + t.Fatalf("at N: %d hosts, %d evicted, %v", len(at.Hosts), res.Evicted, res.Err) + } + over, res, _ := Fold(fileWith(maxBaselineHosts), true, "s1", time.UnixMilli(1<<40), []string{"new.example", "h0.example"}) + if res.Err != nil || len(over.Hosts) != maxBaselineHosts || res.Evicted != 1 { + t.Fatalf("at N+1: %d hosts, %d evicted, %v", len(over.Hosts), res.Evicted, res.Err) + } + if _, ok := over.Hosts["h1.example"]; ok { + t.Errorf("at N+1 the least recently seen host not named by this session, h1, survived") + } + for _, h := range []string{"h0.example", "new.example", "h2.example"} { + if _, ok := over.Hosts[h]; !ok { + t.Errorf("at N+1 %s went", h) + } + } + if !reflect.DeepEqual(res.Novel, []string{"new.example"}) { + t.Errorf("novel %v, want [new.example]", res.Novel) + } + // An old session rendered late names h0, the least recently seen host + // of all: it stays, because this fold reports on it, and h1 goes. + old, res, _ := Fold(fileWith(maxBaselineHosts), true, "s-old", time.UnixMilli(1), []string{"new.example", "h0.example"}) + if _, ok := old.Hosts["h0.example"]; !ok || res.Evicted != 1 { + t.Errorf("an old session's own host was evicted (%d evicted)", res.Evicted) + } + if _, ok := old.Hosts["h1.example"]; ok { + t.Errorf("an old session's fold kept h1, the least recently seen host it did not name") + } + + many := make([]string, maxBaselineHosts+1) + for i := range many { + many[i] = fmt.Sprintf("m%d.example", i) + } + root := t.TempDir() + if r := Update(root, "/p", "s9", t0, many); !errors.Is(r.Err, ErrTooManyHosts) { + t.Fatalf("a session naming %d hosts: %v, want ErrTooManyHosts", len(many), r.Err) + } + if _, err := os.Stat(filepath.Join(root, "baseline", fileName("/p"))); !errors.Is(err, os.ErrNotExist) { + t.Errorf("a baseline was written for a session past the cap: %v", err) + } + if r := Update(root, "/p", "s9", t0, many[:maxBaselineHosts]); r.Err != nil || r.Known != maxBaselineHosts { + t.Errorf("a session naming exactly %d hosts: known %d, %v", maxBaselineHosts, r.Known, r.Err) + } +} + +// TestRubricLLD10_AnUnchangedRenderWritesNothing: a render that learns +// nothing new -- the same session rendered again -- leaves the file as it +// is, rather than rewriting every host the project has seen. +func TestRubricLLD10_AnUnchangedRenderWritesNothing(t *testing.T) { + root, key := t.TempDir(), "/proj" + if r := Update(root, key, "s0", t0, []string{"a.example"}); r.Err != nil { + t.Fatal(r.Err) + } + if r := Update(root, key, "s1", t0.Add(time.Hour), []string{"a.example", "b.example"}); r.Err != nil { + t.Fatal(r.Err) + } + path := filepath.Join(root, "baseline", fileName(key)) + before, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + r := Update(root, key, "s1", t0.Add(time.Hour), []string{"a.example", "b.example"}) + if r.Err != nil || !reflect.DeepEqual(r.Novel, []string{"b.example"}) { + t.Fatalf("re-render: novel %v, %v", r.Novel, r.Err) + } + after, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(before, after) { + t.Errorf("a render that changed nothing replaced the file") + } +} + +// TestRubricLLD10_AnOversizedFileIsUnknown: a baseline file is read whole, +// so it is read only up to maxBaselineBytes: a file of exactly that size is +// read, and one byte more is not read at all -- novelty is unknown, as for a +// corrupt file, rather than an unbounded read. +func TestRubricLLD10_AnOversizedFileIsUnknown(t *testing.T) { + sized := func(n int) []byte { + head := []byte(`{"version":1,"project":"/proj","basis":"declared","established_by_session":"s0","established_at_unix_ms":1,"hosts":{},"pad":"`) + body := append(head, bytes.Repeat([]byte("x"), n-len(head)-2)...) + return append(body, `"}`...) + } + for _, tc := range []struct { + size int + read bool + }{{maxBaselineBytes, true}, {maxBaselineBytes + 1, false}} { + root, key := t.TempDir(), "/proj" + dir := filepath.Join(root, "baseline") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + body := sized(tc.size) + if len(body) != tc.size { + t.Fatalf("built %d bytes, want %d", len(body), tc.size) + } + if err := os.WriteFile(filepath.Join(dir, fileName(key)), body, 0o600); err != nil { + t.Fatal(err) + } + r := Update(root, key, "s1", t0, []string{"a.example"}) + if read := r.Err == nil; read != tc.read { + t.Errorf("a %d-byte baseline: read %v (%v), want %v", tc.size, read, r.Err, tc.read) + } + } +} + +func keys(m map[string]entry) []string { + var out []string + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/store/lockpath.go b/internal/store/lockpath.go new file mode 100644 index 00000000..f41c4f73 --- /dev/null +++ b/internal/store/lockpath.go @@ -0,0 +1,28 @@ +package store + +import "os" + +// LockPath takes the store's exclusive lock on the file at path, creating it +// 0600 if it is absent, waiting at most the store's lock budget, and returns +// ErrLockTimeout when that runs out. +// +// It is for a caller beside the store that does a load-modify-save of a file +// of its own: the project baseline, which a report render and a `forget +// --host` both rewrite. The path is a sidecar lock file, not the data file, +// because such a save renames a new file over the data file, and a lock held +// on the inode it replaced would guard nothing. release unlocks and closes. +func LockPath(path string) (release func(), err error) { + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return nil, err + } + unlock, _, err := lockCounted(f, lockBudget) + if err != nil { + f.Close() //nolint:errcheck // the lock error is what matters + return nil, err + } + return func() { + unlock() + f.Close() //nolint:errcheck // a lock file: nothing was written to it + }, nil +} diff --git a/test/acceptance/tru58_baseline_test.go b/test/acceptance/tru58_baseline_test.go index 161f7056..a77647b7 100644 --- a/test/acceptance/tru58_baseline_test.go +++ b/test/acceptance/tru58_baseline_test.go @@ -352,8 +352,15 @@ func baselineFile(t *testing.T, e *env) string { if err != nil { t.Fatal(err) } - if len(entries) != 1 { - t.Fatalf("premise: %d files under baseline/, want the one project's", len(entries)) + // The project files, not the lock file beside them. + var files []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".json") { + files = append(files, e.Name()) + } + } + if len(files) != 1 { + t.Fatalf("premise: %d project files under baseline/, want the one project's", len(files)) } - return filepath.Join(dir, entries[0].Name()) + return filepath.Join(dir, files[0]) } diff --git a/test/mutation/mutants/RES-11_baseline-hosts.py b/test/mutation/mutants/RES-11_baseline-hosts.py new file mode 100644 index 00000000..3165dd69 --- /dev/null +++ b/test/mutation/mutants/RES-11_baseline-hosts.py @@ -0,0 +1,14 @@ +# RES-11, the baseline's row (rubric lld-10, fix list 53): a project baseline +# holds at most 4,096 hosts, the least recently seen going first and never +# one the folding session named, and a session that alone names more is +# unknown rather than folded. +m("RES-11 the 4,096-host check is removed", "internal/baseline/fold.go", + "\tres.Evicted = evict(out.Hosts, named)\n", "\tres.Evicted = 0\n", "TestRES11_Every") +m("RES-11 the baseline cap is one host larger", "internal/baseline/fold.go", + "const maxBaselineHosts = 4096\n", "const maxBaselineHosts = 4097\n", "TestRES11_Every") +m("RES-11 a session past the cap is folded", "internal/baseline/fold.go", + "\tif len(named) > maxBaselineHosts {\n", "\tif false {\n", "TestRES11_Every") +m("RES-11 the most recently seen host goes first", "internal/baseline/fold.go", + "\t\tif a != b {\n\t\t\treturn a < b\n", "\t\tif a != b {\n\t\t\treturn a > b\n", "TestRES11_Every") +m("RES-11 a host the session named can be evicted", "internal/baseline/fold.go", + "\t\tif !keep[h] {\n", "\t\tif true {\n", "TestRES11_Every") diff --git a/test/mutation/mutants/RUBRIC_hld-2_baseline-lock.py b/test/mutation/mutants/RUBRIC_hld-2_baseline-lock.py new file mode 100644 index 00000000..795939ae --- /dev/null +++ b/test/mutation/mutants/RUBRIC_hld-2_baseline-lock.py @@ -0,0 +1,13 @@ +# rubric hld-2 (fix list 28): a render's load-fold-save and a forget's +# rewrite of a project baseline happen under one lock, around a pure fold. +# Saving without the lock, releasing it before the forget's rewrite, or a +# fold that edits the file it was handed is the regression. +m("RUBRIC hld-2 a render saves without the lock", "internal/baseline/baseline.go", + "\trelease, err := store.LockPath(filepath.Join(dir, lockName))\n\tif err != nil {\n\t\treturn Result{}, err\n\t}\n\tdefer release()\n", + "", "TestRubricHLD2_ConcurrentRendersLoseNoHost") +m("RUBRIC hld-2 a forget rewrites after releasing the lock", "internal/baseline/baseline.go", + "\tdefer release()\n\treturn forgetLocked(path, host)\n", "\trelease()\n\treturn forgetLocked(path, host)\n", + "TestRubricHLD2_ForgetAndRenderLoseNoChange") +m("RUBRIC hld-2 the fold edits the file it was handed", "internal/baseline/fold.go", + "\tout.Hosts = maps.Clone(f.Hosts)\n", "\tout.Hosts = f.Hosts\n", "TestRubricHLD2_FoldIsPure") +IMPORTS["RUBRIC hld-2 the fold edits the file it was handed"] = ("internal/baseline/fold.go", "\t\"maps\"\n", "") diff --git a/test/mutation/mutants/RUBRIC_lld-10_baseline-writes.py b/test/mutation/mutants/RUBRIC_lld-10_baseline-writes.py new file mode 100644 index 00000000..d5b52e08 --- /dev/null +++ b/test/mutation/mutants/RUBRIC_lld-10_baseline-writes.py @@ -0,0 +1,9 @@ +# rubric lld-10 (fix list 53): a render that learns nothing writes nothing, +# and a baseline file past its derived size is not read whole: exactly the +# size is read, one byte more is refused. +m("RUBRIC lld-10 an unchanged render rewrites the file", "internal/baseline/baseline.go", + "\tif res.Err != nil || !changed {\n", "\tif res.Err != nil || !changed && false {\n", "TestRubricLLD10_") +m("RUBRIC lld-10 an oversized baseline is read", "internal/baseline/fold.go", + "\tif len(body) > maxBaselineBytes {\n\t\treturn nil, errTooLarge\n", "\tif false {\n\t\treturn nil, errTooLarge\n", "TestRubricLLD10_") +m("RUBRIC lld-10 a baseline of exactly the bound is refused", "internal/baseline/fold.go", + "\tif len(body) > maxBaselineBytes {\n\t\treturn nil, errTooLarge\n", "\tif len(body) >= maxBaselineBytes {\n\t\treturn nil, errTooLarge\n", "TestRubricLLD10_") From 721cf2c63e0aabb4d25a3cc468aaa6fae776bf2b Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 11:23:26 -0700 Subject: [PATCH 07/50] bounds: test every input bound at N and N+1 Most input bounds were never tested at their edge, so a bound could move by one, or the reader applying it could read one byte past it, and every suite would pass. One table test per bound now holds the value and the edge: - store maxLine (16 MiB): a record line of exactly maxLine bytes is read, one byte more fails the read with bufio.ErrTooLong and hands back no part of the record; - recap maxTrackedSessions (64): 64 sessions are all kept in both maps of recap.json, 65 are cut back to 64; - spend maxTokens (100,000,000): exactly the bound is plausible and one more is not, in each of the seven fields a usage carries; - spend maxLine (64 MiB): a transcript line of exactly maxLine bytes is read and its response priced, one more marks the file unreadable; - report messageCap (64 Ki runes): exactly the cap is kept whole, one more rune is cut back to it; - report transcript maxLine (64 MiB): a line of exactly maxLine bytes has its tool call collected, one more stops the read with an error. The edge tests found what the three line bounds mean: they count the line's newline, because bufio.Scanner needs room for the terminator to find it, so the longest line read is maxLine-1 bytes. The comments on the three constants now say so. The payload cap was already tested at N and N+1 by RES-4 (TestRES4_CapAtNAndNPlusOne); maxNest, the token bound and the leading-cd bound have rows of their own in this series. Mutants in RUBRIC_test-8_bounds.py: each value moved, the store and transcript readers taking one byte past the bound, spend reading one short, recap keeping one session fewer, an exact maxTokens refused and the message cut one rune short. All eight are red. Not changed here, and worth a decision: recap's cap evicts in map order, which can drop the session it has just recorded and lose that turn's idempotency once the file is at the cap. Rubric fix list entry 17 (test-8). --- internal/recap/rubric_test8_test.go | 35 +++++++++++ internal/report/rubric_test8_test.go | 47 +++++++++++++++ internal/report/transcript.go | 2 + internal/spend/rubric_test8_test.go | 59 +++++++++++++++++++ internal/spend/scan.go | 3 +- internal/store/rubric_test8_test.go | 45 ++++++++++++++ test/mutation/mutants/RUBRIC_test-8_bounds.py | 22 +++++++ 7 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 internal/recap/rubric_test8_test.go create mode 100644 internal/report/rubric_test8_test.go create mode 100644 internal/spend/rubric_test8_test.go create mode 100644 internal/store/rubric_test8_test.go create mode 100644 test/mutation/mutants/RUBRIC_test-8_bounds.py diff --git a/internal/recap/rubric_test8_test.go b/internal/recap/rubric_test8_test.go new file mode 100644 index 00000000..b36f7d03 --- /dev/null +++ b/internal/recap/rubric_test8_test.go @@ -0,0 +1,35 @@ +package recap + +import ( + "fmt" + "path/filepath" + "testing" +) + +// TestRubricTEST8_TrackedSessionsAtNAndNPlusOne: recap.json remembers at most +// maxTrackedSessions sessions in each of its maps: 64 are all kept, and at +// 65 the map is cut back to 64. +func TestRubricTEST8_TrackedSessionsAtNAndNPlusOne(t *testing.T) { + if maxTrackedSessions != 64 { + t.Fatalf("maxTrackedSessions = %d, want 64", maxTrackedSessions) + } + for _, n := range []int{maxTrackedSessions, maxTrackedSessions + 1} { + s := state{Spoken: map[string]string{}, Checked: map[string]string{}} + for i := 0; i < n; i++ { + id := fmt.Sprintf("sess-%03d", i) + s.Spoken[id], s.Checked[id] = "turn", "turn" + } + path := filepath.Join(t.TempDir(), stateFileName) + if err := writeState(path, s); err != nil { + t.Fatal(err) + } + got, err := readState(path) + if err != nil { + t.Fatal(err) + } + want := min(n, maxTrackedSessions) + if len(got.Spoken) != want || len(got.Checked) != want { + t.Errorf("%d sessions written: %d spoken and %d checked kept, want %d", n, len(got.Spoken), len(got.Checked), want) + } + } +} diff --git a/internal/report/rubric_test8_test.go b/internal/report/rubric_test8_test.go new file mode 100644 index 00000000..81fc8f98 --- /dev/null +++ b/internal/report/rubric_test8_test.go @@ -0,0 +1,47 @@ +package report + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestRubricTEST8_MessageCapAtNAndNPlusOne: a caller's final message is cut +// to messageCap runes before it is compared with anything: exactly that many +// are kept whole, and one more is cut back to them. +func TestRubricTEST8_MessageCapAtNAndNPlusOne(t *testing.T) { + if messageCap != 64<<10 { + t.Fatalf("messageCap = %d, want 64 Ki", messageCap) + } + for _, n := range []int{messageCap, messageCap + 1} { + got := sanitizeMessage(strings.Repeat("é", n)) + if len([]rune(got)) != min(n, messageCap) { + t.Errorf("%d runes in: %d kept, want %d", n, len([]rune(got)), min(n, messageCap)) + } + } +} + +// TestRubricTEST8_TranscriptLineAtNAndNPlusOne: a transcript line of maxLine +// bytes, counting its newline, is read and its tool call collected; one byte +// more stops the read with an error rather than a silent truncation. +func TestRubricTEST8_TranscriptLineAtNAndNPlusOne(t *testing.T) { + const head, tail = `{"message":{"content":[{"type":"tool_use","id":"toolu_n"}]},"pad":"`, "\"}\n" + if maxLine != 64<<20 { + t.Fatalf("maxLine = %d, want 64 MiB", maxLine) + } + for _, tc := range []struct { + n int + read bool + }{{maxLine, true}, {maxLine + 1, false}} { + path := filepath.Join(t.TempDir(), "t.jsonl") + if err := os.WriteFile(path, []byte(head+strings.Repeat("x", tc.n-len(head)-len(tail))+tail), 0o600); err != nil { + t.Fatal(err) + } + ids := map[string]bool{} + _, err := collectIDs(path, ids, map[string]bool{}, map[string]bool{}, map[string]bool{}) + if read := err == nil && ids["toolu_n"]; read != tc.read { + t.Errorf("a %d-byte line: collected %v, %v; want read %v", tc.n, ids, err, tc.read) + } + } +} diff --git a/internal/report/transcript.go b/internal/report/transcript.go index cdc52e90..f2b6a60f 100644 --- a/internal/report/transcript.go +++ b/internal/report/transcript.go @@ -15,6 +15,8 @@ import ( // maxLine bounds one transcript line. Transcript lines carry whole messages // and can be large; the bound is against a corrupted file, not a real one. +// It counts the line's newline, which the scanner needs room to find: the +// longest line read is maxLine-1 bytes. const maxLine = 64 << 20 // deniedPrefix is the text Claude Code writes when the USER refuses a tool diff --git a/internal/spend/rubric_test8_test.go b/internal/spend/rubric_test8_test.go new file mode 100644 index 00000000..9aeb9664 --- /dev/null +++ b/internal/spend/rubric_test8_test.go @@ -0,0 +1,59 @@ +package spend + +import ( + "os" + "strings" + "testing" + "time" +) + +// TestRubricTEST8_TokenCountAtNAndNPlusOne: a count of exactly maxTokens is a +// response's; one more is not, in every field a usage carries. +func TestRubricTEST8_TokenCountAtNAndNPlusOne(t *testing.T) { + if maxTokens != 100_000_000 { + t.Fatalf("maxTokens = %d, want 100,000,000", maxTokens) + } + set := []func(u *usage, v int64){ + func(u *usage, v int64) { u.Input = v }, + func(u *usage, v int64) { u.Output = v }, + func(u *usage, v int64) { u.CacheRead = v }, + func(u *usage, v int64) { u.CacheCreation = v }, + func(u *usage, v int64) { u.CacheSplit = &cacheCreation{Ephemeral5m: v} }, + func(u *usage, v int64) { u.CacheSplit = &cacheCreation{Ephemeral1h: v} }, + func(u *usage, v int64) { u.Iterations = []iteration{{tokens: tokens{Input: v}}} }, + } + for i, f := range set { + var at, over usage + f(&at, maxTokens) + f(&over, maxTokens+1) + if !at.plausible() || over.plausible() { + t.Errorf("field %d: %d plausible %v, %d plausible %v; want true, false", i, int64(maxTokens), at.plausible(), int64(maxTokens+1), over.plausible()) + } + } +} + +// TestRubricTEST8_SpendLineAtN: a transcript line of exactly maxLine bytes, +// counting its newline, is read and its response priced; one byte more ends +// the file's read, which is counted as unreadable. +func TestRubricTEST8_SpendLineAtN(t *testing.T) { + if maxLine != 64<<20 { + t.Fatalf("maxLine = %d, want 64 MiB", maxLine) + } + for _, tc := range []struct { + n int + read bool + }{{maxLine, true}, {maxLine + 1, false}} { + c := newConfig(t) + base := resp{id: "big", model: "claude-opus-5-5", at: now.Add(-time.Hour), in: 1000, stop: "end_turn"}.line("text") + head := strings.TrimSuffix(base, "}") + `,"pad":"` + line := head + strings.Repeat("x", tc.n-len(head)-len("\"}\n")) + "\"}" + p := c.write("proj/sess-a.jsonl", line) + if fi, err := os.Stat(p); err != nil || fi.Size() != int64(tc.n) { + t.Fatalf("built %v bytes, want %d (%v)", fi.Size(), tc.n, err) + } + s := c.summary(30) + if read := s.scan.Unreadable == 0 && s.Responses == 1; read != tc.read { + t.Errorf("a %d-byte line: %d unreadable, %d responses; want read %v", tc.n, s.scan.Unreadable, s.Responses, tc.read) + } + } +} diff --git a/internal/spend/scan.go b/internal/spend/scan.go index a6dcd725..5c9392d2 100644 --- a/internal/spend/scan.go +++ b/internal/spend/scan.go @@ -20,7 +20,8 @@ import ( // carry whole messages and can be large, and the bound is against a corrupted // file, not a real one. A line longer than this ends that file's read (the // scanner cannot step over it) and the file is counted as unreadable rather -// than silently truncated. +// than silently truncated. It counts the line's newline, which the scanner +// needs room to find: the longest line read is maxLine-1 bytes. const maxLine = 64 << 20 // usageMarker is a byte test run before any decoding of a main transcript's diff --git a/internal/store/rubric_test8_test.go b/internal/store/rubric_test8_test.go new file mode 100644 index 00000000..8ee9b15b --- /dev/null +++ b/internal/store/rubric_test8_test.go @@ -0,0 +1,45 @@ +package store + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestRubricTEST8_StoreLineAtNAndNPlusOne: a record line of maxLine bytes, +// not counting its newline, is read whole; one byte more is not read, and +// the reader hands its caller a nil line in its place, which every caller +// counts as a damaged line, rather than any part of the record +// (forEachLine). +func TestRubricTEST8_StoreLineAtNAndNPlusOne(t *testing.T) { + const head, tail = `{"type":"coverage","pad":"`, `"}` + if maxLine != 16<<20 { + t.Fatalf("maxLine = %d, want 16 MiB", maxLine) + } + for _, tc := range []struct { + n int + read bool + }{{maxLine, true}, {maxLine + 1, false}} { + path := filepath.Join(t.TempDir(), "records.ndjson") + line := head + strings.Repeat("x", tc.n-len(head)-len(tail)) + tail + if len(line) != tc.n { + t.Fatalf("built %d bytes, want %d", len(line), tc.n) + } + if err := os.WriteFile(path, []byte(line+"\n"), 0o600); err != nil { + t.Fatal(err) + } + var got [][]byte + if err := eachLine(path, func(l []byte) { got = append(got, l) }); err != nil { + t.Fatalf("a %d-byte line: %v", tc.n, err) + } + switch { + case len(got) != 1: + t.Errorf("a %d-byte line: handed over %d lines, want 1", tc.n, len(got)) + case tc.read && len(got[0]) != tc.n: + t.Errorf("a %d-byte line: read %d bytes, want all %d", tc.n, len(got[0]), tc.n) + case !tc.read && got[0] != nil: + t.Errorf("a %d-byte line: read %d bytes of it, want a nil line in its place", tc.n, len(got[0])) + } + } +} diff --git a/test/mutation/mutants/RUBRIC_test-8_bounds.py b/test/mutation/mutants/RUBRIC_test-8_bounds.py new file mode 100644 index 00000000..53392358 --- /dev/null +++ b/test/mutation/mutants/RUBRIC_test-8_bounds.py @@ -0,0 +1,22 @@ +# rubric test-8 (fix list 17): every input bound is tested at N and N+1. +# Moving a bound's value, or reading past it or short of it where it is +# applied, is the regression. (The payload cap is RES-4's; maxNest, the +# token bound and the leading-cd bound have rows of their own.) +m("RUBRIC test-8 the store reads a line one byte past maxLine", "internal/store/lines.go", + "\t\t\tif len(line)+len(chunk) > maxLine {", "\t\t\tif len(line)+len(chunk) > maxLine+1 {", "TestRubricTEST8_") +m("RUBRIC test-8 the store's maxLine moves", "internal/store/lines.go", + "const maxLine = 16 << 20\n", "const maxLine = 16<<20 - 1\n", "TestRubricTEST8_") +m("RUBRIC test-8 recap keeps one session fewer", "internal/recap/state.go", + "\t\tif len(m) <= maxTrackedSessions {\n\t\t\treturn\n\t\t}\n\t\tif id", "\t\tif len(m) < maxTrackedSessions {\n\t\t\treturn\n\t\t}\n\t\tif id", "TestRubricTEST8_") +m("RUBRIC test-8 a count of exactly maxTokens is implausible", "internal/spend/scan.go", + "\t\t\tif v < 0 || v > maxTokens {\n", "\t\t\tif v < 0 || v >= maxTokens {\n", "TestRubricTEST8_") +m("RUBRIC test-8 spend reads one byte short of maxLine", "internal/spend/scan.go", + "\ts.Buffer(make([]byte, 0, 256*1024), maxLine)\n", "\ts.Buffer(make([]byte, 0, 256*1024), maxLine-1)\n", "TestRubricTEST8_") +m("RUBRIC test-8 a final message is cut one rune short", "internal/report/account.go", + "\t\trunes = runes[:messageCap]\n", "\t\trunes = runes[:messageCap-1]\n", "TestRubricTEST8_") +m("RUBRIC test-8 the transcript's maxLine moves", "internal/report/transcript.go", + "const maxLine = 64 << 20\n", "const maxLine = 64<<20 + 1\n", "TestRubricTEST8_") +m("RUBRIC test-8 the transcript reader takes a line past maxLine", "internal/report/transcript.go", + "\tdefer f.Close() //nolint:errcheck // read-only\n\n\tsc := bufio.NewScanner(f)\n\tsc.Buffer(make([]byte, 0, 256*1024), maxLine)\n\tfor sc.Scan() {\n\t\tvar line struct {\n\t\t\tMessage struct {\n\t\t\t\tContent json.RawMessage `json:\"content\"`\n", + "\tdefer f.Close() //nolint:errcheck // read-only\n\n\tsc := bufio.NewScanner(f)\n\tsc.Buffer(make([]byte, 0, 256*1024), maxLine+1)\n\tfor sc.Scan() {\n\t\tvar line struct {\n\t\t\tMessage struct {\n\t\t\t\tContent json.RawMessage `json:\"content\"`\n", + "TestRubricTEST8_") From 176f0130c0c3bac34990de01ef1f90d900833b7d Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 12:16:49 -0700 Subject: [PATCH 08/50] shape: read the leading cds of a line cut at the token bound leadingDirectories gave up on any lexer error, the token bound's included, so a Bash line that ran past 42,473 tokens with a leading cd kept the payload's cwd: `cd /elsewhere && go test` and 50,000 words, or `cd sub && python3 - < 50*time.Millisecond { t.Errorf("%s: the fold took %v, want at most 50ms", tc.name, took) } - if strings.Count(got, "/a") > 64 { - t.Errorf("%s: %d steps folded, want at most 64", tc.name, strings.Count(got, "/a")) + if got != "" { + t.Errorf("%s: start directory %q, want unknown", tc.name, got) + } + } + + // Past the token bound: a long line after the leading steps. + long := " " + strings.TrimSpace(strings.Repeat("word ", 50_000)) + for _, tc := range []struct { + name, cmd, want string + }{ + {"one cd, then 50,000 words", "cd /elsewhere && go test" + long, "/elsewhere"}, + {"one cd, then a 50,000-word here-document", + "cd sub && python3 - <<'EOF'\n" + long + "\nEOF\nmake test", "/repo/sub"}, + {"64 steps, then 50,000 words", cdLine(64, " && ") + long, "/repo" + strings.Repeat("/a", 64)}, + {"100 steps, then 50,000 words", cdLine(100, " && ") + long, ""}, + } { + if got := startDirectory("/repo", "Bash", in(tc.cmd)); got != tc.want { + t.Errorf("%s: start directory %q, want %q", tc.name, got, tc.want) } } } diff --git a/internal/shape/res10_test.go b/internal/shape/res10_test.go index f676b44a..57835893 100644 --- a/internal/shape/res10_test.go +++ b/internal/shape/res10_test.go @@ -37,3 +37,24 @@ func TestRES10_ProgramPastManyDirectoryChanges(t *testing.T) { t.Errorf("program %v, want go", show(s.Program)) } } + +// TestRES10_LeadingStepsPastTheTokenBound: a line that runs on past the +// token bound (RES-9) still has its leading steps read, since they come first +// and the longest run read, MaxLeadingCd+1 steps of three tokens, lies far +// inside the bound. The masked-status walk counts the same steps the hook +// folds into the cwd digest: one cd in front of a 50,000-word here-document +// is one step held, and 100 in front of 50,000 words are past the fold. +func TestRES10_LeadingStepsPastTheTokenBound(t *testing.T) { + long := strings.TrimSpace(strings.Repeat("word ", 50_000)) + heredoc := "cd sub && python3 - <<'EOF'\n" + long + "\nEOF\nmake test" + if _, err := tokenizeProgram(heredoc); err != errTokenBound { + t.Fatalf("the here-document line reads with %v, want errTokenBound", err) + } + if got := leadingSteps(heredoc); got != 1 { + t.Errorf("one cd before a long here-document: leadingSteps %d, want 1", got) + } + dirs, ok := leadingDirectories(strings.Repeat("cd a && ", 100) + long) + if !ok || len(dirs) != MaxLeadingCd+1 { + t.Errorf("100 cds before 50,000 words: %d directories, %v; want %d, true", len(dirs), ok, MaxLeadingCd+1) + } +} diff --git a/internal/shape/shape.go b/internal/shape/shape.go index 06e2f53d..460d9f84 100644 --- a/internal/shape/shape.go +++ b/internal/shape/shape.go @@ -480,6 +480,11 @@ func LeadingDirectory(toolName string, toolInput json.RawMessage) ([]string, boo // a millisecond. const MaxLeadingCd = 64 +// The longest leading run leadingDirectories reads, MaxLeadingCd+1 steps of +// three tokens, lies inside the token bound: this fails to compile otherwise, +// since a run the bound cut short is not where the line ran. +const _ = uint(maxTokens - 3*(MaxLeadingCd+1)) + // leadingSteps is how many leading `cd DIR &&` steps LeadingDirectory folds // onto a call's cwd for the line cmd: the cds on it that the call's cwd // digest already holds. None past MaxLeadingCd, where the hook records the @@ -494,9 +499,17 @@ func leadingSteps(cmd string) int { } // leadingDirectories is LeadingDirectory over the line cmd. +// +// A line cut at the token bound (errTokenBound) is read to the bound, since +// its leading steps come first: the most this reads is MaxLeadingCd+1 steps +// of three tokens, far inside the bound (see the constant below), so the cut +// never falls in the run. Giving up there instead kept the payload's cwd for +// `cd sub && python3 - < MaxLeadingCd {\n\t\treturn 0\n\t}\n", "", "TestRES10_RunnerPastTheFoldIsNotPaired") +m("RES-10 a line cut at the token bound gives up its leading cds", "internal/shape/shape.go", + "\tif err != nil && err != errTokenBound {\n\t\treturn nil, false\n\t}\n", + "\tif err != nil {\n\t\treturn nil, false\n\t}\n", "TestRES10_") From 509ef58acc0b60fa96dae80a5924b085501c5b04 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 12:33:23 -0700 Subject: [PATCH 09/50] shape: split lex into a lexer and one method per construct lex was 244 lines against the 100-line limit (Law 10): one loop holding its state in locals and five closures, with each construct's reading written inline in a case of its switch. The state moves to a lexer struct, the closures become its methods, and each case calls a method that reads the construct beginning at s[i] -- a blank, a comment, an escape, a single- or double-quoted string, an operator or a plain byte -- and returns the index of its last byte. lex is now the loop and the dispatch, 37 lines; the longest method, double, is 55. The behaviour is unchanged: each method is the case it replaces, with the locals read through the lexer, and an error returns the tokens emitted so far, as before. The 24 mutants anchored inside lex are re-anchored on the methods with the same changes, and each is still turned red by the tests it names. --- internal/shape/tokenize.go | 505 ++++++++++++--------- test/mutation/mutants/H-13_shape.py | 34 +- test/mutation/mutants/H-3_install.py | 4 +- test/mutation/mutants/MS_masked.py | 10 +- test/mutation/mutants/RES-9_token-bound.py | 4 +- 5 files changed, 309 insertions(+), 248 deletions(-) diff --git a/internal/shape/tokenize.go b/internal/shape/tokenize.go index 0fff75b4..5ff9d120 100644 --- a/internal/shape/tokenize.go +++ b/internal/shape/tokenize.go @@ -112,7 +112,7 @@ type token struct { // carries that is a property of that word alone. Emitting the word resets // all of it at once (reset), so a field added here is cleared with the rest. // The bytes between words -- whether a blank or a newline came before the next -// one -- are not a word's, and stay with lex. +// one -- are not a word's, and stay with the lexer. type word struct { text strings.Builder started bool @@ -215,248 +215,309 @@ func tokenizeList(s string) ([]token, error) { // // docs selects tokenizeList's reading of here-documents and comments. func lex(s string, program, docs bool, joins []int) ([]token, error) { - var ( - toks = make([]token, 0, tokenEstimate(s)) - w = newWord() - sawNL bool - // gap: a blank or newline since the last token ended. - gap bool - // dollarAt: the index of the last unquoted, unescaped `$`, and - // dollars: how many of them run together up to it. - dollarAt = -1 - dollars int - // expanded: a token already emitted was opaque. - expanded bool - // pending: for docs, the offsets just past each here-document - // operator on the current line, whose bodies begin at its end. - // grouped: a paren or a backtick has been seen. - pending []int - grouped bool - ) - - // begin marks the current word started, noting at its first byte - // whether it is glued to the token before it. - begin := func() { - if !w.started { - w.glued = !gap && len(toks) > 0 - w.started = true - } - } - // markQuoted records where quoting first touched the current token. - markQuoted := func() { - if w.quotedAt < 0 { - w.quotedAt = w.text.Len() - } - } - // expands reports whether s[i] opens an expansion this tokenizer does - // not parse. inDouble: inside "", $'' and $"" are not quoting forms. - expands := func(i int, inDouble bool) bool { - if s[i] == '`' { - return true + l := &lexer{s: s, program: program, docs: docs, joins: joins, w: newWord(), dollarAt: -1} + l.toks = make([]token, 0, tokenEstimate(s)) + for i := 0; i < len(s); i++ { + if len(l.toks) > maxTokens { + // Past the token bound: see bounded. + return l.toks, errTokenBound } - if s[i] != '$' || i+1 >= len(s) { - return false + var err error + switch c := s[i]; { + case c == ' ' || c == '\t' || c == '\n' || c == '\r': + i, err = l.blank(i) + case docs && c == '#' && !l.w.started: + i = l.comment(i) + case c == '\\': + i, err = l.backslash(i) + case c == '\'': + i, err = l.single(i) + case c == '"': + i, err = l.double(i) + case isMeta(c): + i, err = l.operator(i) + default: + l.plain(i) } - switch s[i+1] { - case '(', '{', '[': - return true - case '\'', '"': - return !inDouble + if err != nil { + return l.toks, err } - return false } - // unterminated is the error for a quote that never closes. For the - // program search, after an expansion it is uncertain: see lex. - unterminated := func() error { - if program && (w.opaque || expanded) { - return errUncertain - } - return errUnterminated + if len(l.pending) > 0 { + // A here-document on the last line, whose body never came. + return l.toks, errUncertain } - flush := func() { - if w.started { - toks = append(toks, token{text: w.text.String(), quotedAt: w.quotedAt, opaque: w.opaque, nlBefore: sawNL, glued: w.glued, ticks: w.ticks}) - expanded = expanded || w.opaque - sawNL, gap = false, false - w.reset() - } + + l.flush() + return l.toks, nil +} + +// lexer is lex's state over the line s: the word being built, w, and the +// state between words and of the line so far. Each method reads the +// construct that begins at s[i] and returns the index of its last byte, from +// which lex moves on; an error stops lex with the tokens emitted so far. +type lexer struct { + s string + program, docs bool + joins []int + toks []token + w word + sawNL bool + // gap: a blank or newline since the last token ended. + gap bool + // dollarAt: the index of the last unquoted, unescaped `$`, and + // dollars: how many of them run together up to it. + dollarAt int + dollars int + // expanded: a token already emitted was opaque. + expanded bool + // pending: for docs, the offsets just past each here-document + // operator on the current line, whose bodies begin at its end. + // grouped: a paren or a backtick has been seen. + pending []int + grouped bool +} + +// begin marks the current word started, noting at its first byte whether it +// is glued to the token before it. +func (l *lexer) begin() { + if w := &l.w; !w.started { + w.glued = !l.gap && len(l.toks) > 0 + w.started = true } +} - for i := 0; i < len(s); i++ { - if len(toks) > maxTokens { - // Past the token bound: see bounded. - return toks, errTokenBound - } - c := s[i] +// markQuoted records where quoting first touched the current token. +func (l *lexer) markQuoted() { + if w := &l.w; w.quotedAt < 0 { + w.quotedAt = w.text.Len() + } +} - switch { - case c == ' ' || c == '\t' || c == '\n' || c == '\r': - flush() - gap = true - if c == '\n' { - sawNL = true - } - if c == '\n' && len(pending) > 0 { - bodies := make([]heredoc, 0, len(pending)) - for _, at := range pending { - d, end := heredocDelim(s, at) - if end < 0 { - return toks, errUncertain - } - bodies = append(bodies, d) - } - e := joined{s, joins}.bodiesEnd(i+1, bodies) - if e < 0 { - return toks, errUncertain - } - pending = nil - // The loop moves past the newline that ends the last - // delimiter line, which the next token comes after. - i = e - } +// expands reports whether s[i] opens an expansion this tokenizer does not +// parse. inDouble: inside double quotes, $'...' and $"..." are not quoting +// forms. +func (l *lexer) expands(i int, inDouble bool) bool { + s := l.s + if s[i] == '`' { + return true + } + if s[i] != '$' || i+1 >= len(s) { + return false + } + switch s[i+1] { + case '(', '{', '[': + return true + case '\'', '"': + return !inDouble + } + return false +} - case docs && c == '#' && !w.started: - for i+1 < len(s) && s[i+1] != '\n' { - i++ - } +// unterminated is the error for a quote that never closes. For the program +// search, after an expansion it is uncertain: see lex. +func (l *lexer) unterminated() error { + if l.program && (l.w.opaque || l.expanded) { + return errUncertain + } + return errUnterminated +} - case c == '\\': - if i+1 >= len(s) { - flush() - return toks, unterminated() - } - i++ - if s[i] != '\n' { // a backslash-newline is a line continuation - begin() - markQuoted() - w.text.WriteByte(s[i]) - } +// flush emits the current word, if one was started. +func (l *lexer) flush() { + if w := &l.w; w.started { + l.toks = append(l.toks, token{text: w.text.String(), quotedAt: w.quotedAt, opaque: w.opaque, nlBefore: l.sawNL, glued: w.glued, ticks: w.ticks}) + l.expanded = l.expanded || w.opaque + l.sawNL, l.gap = false, false + w.reset() + } +} - case c == '\'': - j := strings.IndexByte(s[i+1:], '\'') - if program && dollarAt >= 0 && dollarAt == i-1 { - if dollars%2 == 0 { - // $$': the shells disagree. See lex. - return toks, errUncertain - } - // $'...', which a backslash-escaped quote does not end. - j = ansiCEnd(s[i+1:]) - } - if j < 0 { - return toks, unterminated() - } - begin() - markQuoted() - w.text.WriteString(s[i+1 : i+1+j]) - i += j + 1 +// blank ends the word at the blank or newline s[i]. For docs, a newline after +// a here-document operator skips the bodies that begin after it, to the +// newline that ends the last delimiter line, which the next token comes after. +func (l *lexer) blank(i int) (int, error) { + l.flush() + l.gap = true + if l.s[i] != '\n' { + return i, nil + } + l.sawNL = true + if len(l.pending) == 0 { + return i, nil + } + bodies := make([]heredoc, 0, len(l.pending)) + for _, at := range l.pending { + d, end := heredocDelim(l.s, at) + if end < 0 { + return i, errUncertain + } + bodies = append(bodies, d) + } + e := joined{l.s, l.joins}.bodiesEnd(i+1, bodies) + if e < 0 { + return i, errUncertain + } + l.pending = nil + return e, nil +} - case c == '"': - var closed bool - begin() - markQuoted() - i++ - // dq: how many $ run together up to s[i] inside the quotes. - dq := 0 - for ; i < len(s); i++ { - if s[i] == '$' { - dq++ - } else { - dq = 0 - } - if s[i] == '\\' && i+1 < len(s) { - switch n := s[i+1]; n { - case '"', '\\', '$', '`': - w.text.WriteByte(n) - i++ - continue - case '\n': - i++ - continue - } - w.text.WriteByte('\\') - continue - } - if s[i] == '"' { - closed = true - break - } - if program && s[i] == '$' && i+1 < len(s) && s[i+1] == '(' { - // A context of its own, in which quotes nest: the - // string goes on after the ) that closes it. - end := -1 - if dq%2 == 1 { - end = joined{s, joins}.substEnd(i, 0) - } - if end < 0 { - return toks, errUncertain - } - w.opaque = true - w.text.WriteString(s[i : end+1]) - i = end - continue - } - if expands(i, true) { - w.opaque = true - } - if s[i] == '`' { - w.ticks++ - } - w.text.WriteByte(s[i]) - } - if !closed { - return toks, unterminated() - } +// comment reads, for docs, an unquoted # at the start of a word to the end of +// its line. +func (l *lexer) comment(i int) int { + for i+1 < len(l.s) && l.s[i+1] != '\n' { + i++ + } + return i +} - case isMeta(c): - flush() - j := i + 1 - if j < len(s) && s[j] == c { - switch c { - case '|', '&', '>', '<': - j++ - } - } - toks = append(toks, token{text: s[i:j], meta: true, quotedAt: -1, nlBefore: sawNL, glued: !gap && len(toks) > 0}) - sawNL, gap = false, false - if docs && c == '(' { - grouped = true - } - if docs && c == '<' && j-i == 2 && (j >= len(s) || s[j] != '<') { - // A here-document operator, and not a here-string's <<<. - if grouped || expanded { - return toks, errUncertain - } - pending = append(pending, j) - } - i = j - 1 +// backslash reads the escape at s[i]: the byte after it, taken as quoted, or +// a newline, which the backslash joins to the line. +func (l *lexer) backslash(i int) (int, error) { + if i+1 >= len(l.s) { + l.flush() + return i, l.unterminated() + } + i++ + if l.s[i] != '\n' { // a backslash-newline is a line continuation + l.begin() + l.markQuoted() + l.w.text.WriteByte(l.s[i]) + } + return i, nil +} - default: - if expands(i, false) { - w.opaque = true +// single reads the single-quoted string that opens at s[i]: to the next +// quote, or for the program search a $'...' string to its first unescaped one. +func (l *lexer) single(i int) (int, error) { + s := l.s + j := strings.IndexByte(s[i+1:], '\'') + if l.program && l.dollarAt >= 0 && l.dollarAt == i-1 { + if l.dollars%2 == 0 { + // $$': the shells disagree. See lex. + return i, errUncertain + } + // $'...', which a backslash-escaped quote does not end. + j = ansiCEnd(s[i+1:]) + } + if j < 0 { + return i, l.unterminated() + } + l.begin() + l.markQuoted() + l.w.text.WriteString(s[i+1 : i+1+j]) + return i + j + 1, nil +} + +// double reads the double-quoted string that opens at s[i] to its closing +// quote. +func (l *lexer) double(i int) (int, error) { + s, w := l.s, &l.w + var closed bool + l.begin() + l.markQuoted() + i++ + // dq: how many $ run together up to s[i] inside the quotes. + dq := 0 + for ; i < len(s); i++ { + if s[i] == '$' { + dq++ + } else { + dq = 0 + } + if s[i] == '\\' && i+1 < len(s) { + switch n := s[i+1]; n { + case '"', '\\', '$', '`': + w.text.WriteByte(n) + i++ + continue + case '\n': + i++ + continue } - if c == '$' { - if dollarAt == i-1 { - dollars++ - } else { - dollars = 1 - } - dollarAt = i + w.text.WriteByte('\\') + continue + } + if s[i] == '"' { + closed = true + break + } + if l.program && s[i] == '$' && i+1 < len(s) && s[i+1] == '(' { + // A context of its own, in which quotes nest: the + // string goes on after the ) that closes it. + end := -1 + if dq%2 == 1 { + end = joined{s, l.joins}.substEnd(i, 0) } - if c == '`' { - w.ticks++ - grouped = true + if end < 0 { + return i, errUncertain } - begin() - w.text.WriteByte(c) + w.opaque = true + w.text.WriteString(s[i : end+1]) + i = end + continue + } + if l.expands(i, true) { + w.opaque = true + } + if s[i] == '`' { + w.ticks++ } + w.text.WriteByte(s[i]) } - if len(pending) > 0 { - // A here-document on the last line, whose body never came. - return toks, errUncertain + if !closed { + return i, l.unterminated() + } + return i, nil +} + +// operator emits the operator at s[i], of one byte or a doubled two, and for +// docs notes a here-document operator, whose body begins at the line's end. +func (l *lexer) operator(i int) (int, error) { + s, c := l.s, l.s[i] + l.flush() + j := i + 1 + if j < len(s) && s[j] == c { + switch c { + case '|', '&', '>', '<': + j++ + } + } + l.toks = append(l.toks, token{text: s[i:j], meta: true, quotedAt: -1, nlBefore: l.sawNL, glued: !l.gap && len(l.toks) > 0}) + l.sawNL, l.gap = false, false + if l.docs && c == '(' { + l.grouped = true + } + if l.docs && c == '<' && j-i == 2 && (j >= len(s) || s[j] != '<') { + // A here-document operator, and not a here-string's <<<. + if l.grouped || l.expanded { + return i, errUncertain + } + l.pending = append(l.pending, j) } + return j - 1, nil +} - flush() - return toks, nil +// plain adds the unquoted byte s[i] to the word. +func (l *lexer) plain(i int) { + c := l.s[i] + if l.expands(i, false) { + l.w.opaque = true + } + if c == '$' { + if l.dollarAt == i-1 { + l.dollars++ + } else { + l.dollars = 1 + } + l.dollarAt = i + } + if c == '`' { + l.w.ticks++ + l.grouped = true + } + l.begin() + l.w.text.WriteByte(c) } // tokenEstimate is how many tokens lex will emit from s, for the common line diff --git a/test/mutation/mutants/H-13_shape.py b/test/mutation/mutants/H-13_shape.py index f78afff6..e65406f0 100644 --- a/test/mutation/mutants/H-13_shape.py +++ b/test/mutation/mutants/H-13_shape.py @@ -7,8 +7,8 @@ m("H-13 a word the tokenizer cannot vouch for is read as the program", "internal/shape/shape.go", "\t\tcase t.opaque, isComment(t):", "\t\tcase isComment(t):", "TestNoCorpusLineLeaksIntoProgram") m("H-13 the tokenizer never marks an expansion opaque", "internal/shape/tokenize.go", - "\t\tdefault:\n\t\t\tif expands(i, false) {\n\t\t\t\tw.opaque = true\n\t\t\t}", - "\t\tdefault:\n\t\t\tif expands(i, false) {\n\t\t\t\t_ = w.opaque\n\t\t\t}", "TestNoCorpusLineLeaksIntoProgram") + "\tif l.expands(i, false) {\n\t\tl.w.opaque = true\n\t}", + "\tif l.expands(i, false) {\n\t\t_ = l.w.opaque\n\t}", "TestNoCorpusLineLeaksIntoProgram") m("H-13 backslash-newlines are not joined before the program search", "internal/shape/shape.go", "\tif !strings.Contains(s, \"\\\\\\n\") {\n\t\treturn s, nil\n\t}", "\tif true {\n\t\treturn s, nil\n\t}", "TestNoCorpusLineLeaksIntoProgram|TestTokenizeProgram") @@ -40,9 +40,9 @@ m("H-13 an operator where a redirect target belongs is read past to the next command", "internal/shape/shape.go", "\treturn !procSub(toks, i+1)\n}", "\treturn false\n}", PROG) m("H-13 the tokenizer never marks an operator glued", "internal/shape/tokenize.go", - "nlBefore: sawNL, glued: !gap && len(toks) > 0})", "nlBefore: sawNL, glued: false})", "TestTokenizeGlued|TestProgramIsAProgram") + "nlBefore: l.sawNL, glued: !l.gap && len(l.toks) > 0})", "nlBefore: l.sawNL, glued: false})", "TestTokenizeGlued|TestProgramIsAProgram") m("H-13 the tokenizer never marks a word glued", "internal/shape/tokenize.go", - "\t\t\tw.glued = !gap && len(toks) > 0\n", "\t\t\tw.glued = false\n", "TestTokenizeGlued|TestProgramIsAProgram") + "\t\tw.glued = !l.gap && len(l.toks) > 0\n", "\t\tw.glued = false\n", "TestTokenizeGlued|TestProgramIsAProgram") m("H-13 a byte the shell does not split on is read as part of a program name", "internal/shape/shape.go", "\t\tcase c < 0x21 || c > 0x7e:", "\t\tcase c == ' ' || c == '\\t' || c == '\\n' || c == '\\r':", PROG) m("H-13 a command word holding $ is named", "internal/shape/shape.go", @@ -108,7 +108,7 @@ "\treturn next >= len(toks) || !(toks[next].meta && toks[next].glued && toks[next].text == \"<\")", "\treturn next >= 0", "TestProgramIsAProgram") m("H-13 a trailing backslash after an expansion is read as a real one", "internal/shape/tokenize.go", - "\t\t\t\tflush()\n\t\t\t\treturn toks, unterminated()", "\t\t\t\tflush()\n\t\t\t\treturn toks, errUnterminated", + "\t\tl.flush()\n\t\treturn i, l.unterminated()", "\t\tl.flush()\n\t\treturn i, errUnterminated", "TestTokenizeProgramStops|" + PROG) m("H-13 the function-definition scan reads &> as & and then >", "internal/shape/shape.go", "\t\t\tj++\n\t\t\tfallthrough", "\t\t\tfallthrough", "TestProgramIsAProgram") @@ -151,15 +151,15 @@ # Re-anchored after the masked-status refactor: the backtick case also marks the # line grouped for the list walk's here-document reading; only the count goes. m("H-13 the tokenizer never counts an unquoted backtick", "internal/shape/tokenize.go", - "\t\t\tif c == '`' {\n\t\t\t\tw.ticks++\n\t\t\t\tgrouped = true\n\t\t\t}\n", "\t\t\tif c == '`' {\n\t\t\t\tgrouped = true\n\t\t\t}\n", "TestTokenizeTicks|" + PROG) + "\tif c == '`' {\n\t\tl.w.ticks++\n\t\tl.grouped = true\n\t}\n", "\tif c == '`' {\n\t\tl.grouped = true\n\t}\n", "TestTokenizeTicks|" + PROG) m("H-13 the tokenizer never counts a backtick inside double quotes", "internal/shape/tokenize.go", - "\t\t\t\tif s[i] == '`' {\n\t\t\t\t\tw.ticks++\n\t\t\t\t}\n", "", "TestTokenizeTicks") + "\t\tif s[i] == '`' {\n\t\t\tw.ticks++\n\t\t}\n", "", "TestTokenizeTicks") # Judged by the corpus alone, where the two lines of the PR #29 review are the # only ones this break reopens: "$(echo "a;b")" ends at its first inner quote # again, and the ; inside it ends the search for the () after the command word. m("H-13 a $( ) inside double quotes is not a context in which quotes nest", "internal/shape/tokenize.go", - "\t\t\t\tif program && s[i] == '$' && i+1 < len(s) && s[i+1] == '(' {", - "\t\t\t\tif false && program && s[i] == '$' && i+1 < len(s) && s[i+1] == '(' {", "TestNoCorpusLineLeaksIntoProgram") + "\t\tif l.program && s[i] == '$' && i+1 < len(s) && s[i+1] == '(' {", + "\t\tif false && l.program && s[i] == '$' && i+1 < len(s) && s[i+1] == '(' {", "TestNoCorpusLineLeaksIntoProgram") # The review of that change: each guard comsubEnd and its helpers keep against # a reading the shell may not share. TPROG adds the tokenizer's own tests. TPROG = "TestTokenizeProgram|" + PROG @@ -171,7 +171,7 @@ m("H-13 an even run of $ opens an expansion in a substitution", C, "\t\tif dollars%2 == 0 {\n\t\t\treturn -1\n\t\t}", "\t\tif false {\n\t\t\treturn -1\n\t\t}", TPROG) m("H-13 \"$$(\" opens a substitution", "internal/shape/tokenize.go", - "\t\t\t\t\tif dq%2 == 1 {", "\t\t\t\t\tif true {", TPROG) + "\t\t\tif dq%2 == 1 {", "\t\t\tif true {", TPROG) m("H-13 a $' inside double quotes in a substitution is a quote", C, "\t\t\t\tcontinue // no quote inside double quotes\n", "", TPROG) m("H-13 a nested $( ) is counted as parens", C, "\t\treturn r.substEnd(i, nest)", "\t\treturn i", TPROG) @@ -252,16 +252,16 @@ m("H-13 the program search reads $'...' as argc does", "internal/shape/shape.go", "\tpshaped, perr := tokenizeProgram(cmd)", "\tpshaped, perr := tokenizeShape(cmd)", PROG) m("H-13 any earlier $ opens a $'...' string", "internal/shape/tokenize.go", - "\t\t\tif program && dollarAt >= 0 && dollarAt == i-1 {", "\t\t\tif program && dollarAt >= 0 {", "TestTokenizeProgramReadsANSICQuotes") + "\tif l.program && l.dollarAt >= 0 && l.dollarAt == i-1 {", "\tif l.program && l.dollarAt >= 0 {", "TestTokenizeProgramReadsANSICQuotes") m("H-13 a quote at the start of the line is read as $'...'", "internal/shape/tokenize.go", - "program && dollarAt >= 0 && dollarAt == i-1", "program && dollarAt == i-1", "TestTokenizeProgramReadsANSICQuotes|" + PROG) + "l.program && l.dollarAt >= 0 && l.dollarAt == i-1", "l.program && l.dollarAt == i-1", "TestTokenizeProgramReadsANSICQuotes|" + PROG) m("H-13 bash's $$' is read as zsh's $'...'", "internal/shape/tokenize.go", - "\t\t\t\tif dollars%2 == 0 {", "\t\t\t\tif false {", "TestTokenizeProgramReadsANSICQuotes|" + PROG) + "\t\tif l.dollars%2 == 0 {", "\t\tif false {", "TestTokenizeProgramReadsANSICQuotes|" + PROG) m("H-13 an odd run of $ is read as bash's $$'", "internal/shape/tokenize.go", - "\t\t\t\tif dollars%2 == 0 {", "\t\t\t\tif dollars > 1 {", "TestTokenizeProgramReadsANSICQuotes") + "\t\tif l.dollars%2 == 0 {", "\t\tif l.dollars > 1 {", "TestTokenizeProgramReadsANSICQuotes") m("H-13 an unterminated quote after an expansion is read as a real one", "internal/shape/tokenize.go", - "\t\tif program && (w.opaque || expanded) {", "\t\tif false {", "TestTokenizeProgramStops|" + PROG) + "\tif l.program && (l.w.opaque || l.expanded) {", "\t\tif false {", "TestTokenizeProgramStops|" + PROG) m("H-13 an expansion in an earlier word leaves a later quote certain", "internal/shape/tokenize.go", - "\t\tif program && (w.opaque || expanded) {", "\t\tif program && w.opaque {", "TestTokenizeProgramStops|" + PROG) + "\tif l.program && (l.w.opaque || l.expanded) {", "\tif l.program && l.w.opaque {", "TestTokenizeProgramStops|" + PROG) m("H-13 the count's tokenizer stops where the program search does", "internal/shape/tokenize.go", - "\t\tif program && (w.opaque || expanded) {", "\t\tif w.opaque || expanded {", "TestTokenize") + "\tif l.program && (l.w.opaque || l.expanded) {", "\tif l.w.opaque || l.expanded {", "TestTokenize") diff --git a/test/mutation/mutants/H-3_install.py b/test/mutation/mutants/H-3_install.py index 650a8589..ffa7ab58 100644 --- a/test/mutation/mutants/H-3_install.py +++ b/test/mutation/mutants/H-3_install.py @@ -8,8 +8,8 @@ m("tokenizer does not split on metacharacters", "internal/shape/tokenize.go", "\t\tcase isMeta(c):\n", "\t\tcase isMeta(c) && false:\n", "TestTokenize") m("tokenizer emits the token an unterminated quote interrupted", "internal/shape/tokenize.go", - "\t\t\tif !closed {\n\t\t\t\treturn toks, unterminated()", - "\t\t\tif !closed {\n\t\t\t\tw.started = true\n\t\t\t\tflush()\n\t\t\t\treturn toks, unterminated()", "TestTokenize") + "\tif !closed {\n\t\treturn i, l.unterminated()", + "\tif !closed {\n\t\tw.started = true\n\t\tl.flush()\n\t\treturn i, l.unterminated()", "TestTokenize") m("settings accepts a duplicate key", "internal/settings/document.go", "\t\tif seen[key] {\n\t\t\treturn nil, fmt.Errorf(\"duplicate key %q\", key)\n\t\t}\n\t\tseen[key] = true", "\t\tseen[key] = true", "TestParseRefuses|TestHookEntriesRefusesDuplicateEventKeys") diff --git a/test/mutation/mutants/MS_masked.py b/test/mutation/mutants/MS_masked.py index 0224e85a..17a933ac 100644 --- a/test/mutation/mutants/MS_masked.py +++ b/test/mutation/mutants/MS_masked.py @@ -98,11 +98,11 @@ m("MS the pipeline after && ends at its first stage", "internal/shape/masked.go", "\t\tao.pipes = append(ao.pipes, pl)\n", "\t\tif len(ao.pipes) > 0 {\n\t\t\tpl.stages = pl.stages[:1]\n\t\t}\n\t\tao.pipes = append(ao.pipes, pl)\n", SH) m("MS a here-string is read as a here-document", "internal/shape/tokenize.go", - "\t\t\tif docs && c == '<' && j-i == 2 && (j >= len(s) || s[j] != '<') {", "\t\t\tif docs && c == '<' && j-i == 2 {", SH) + "\tif l.docs && c == '<' && j-i == 2 && (j >= len(s) || s[j] != '<') {", "\tif l.docs && c == '<' && j-i == 2 {", SH) m("MS a here-document after an expansion is read", "internal/shape/tokenize.go", - "\t\t\t\tif grouped || expanded {", "\t\t\t\tif grouped {", SH) + "\t\tif l.grouped || l.expanded {", "\t\tif l.grouped {", SH) m("MS only the first here-document on a line is skipped", "internal/shape/tokenize.go", - "\t\t\t\tpending = append(pending, j)\n", "\t\t\t\tif len(pending) == 0 {\n\t\t\t\t\tpending = append(pending, j)\n\t\t\t\t}\n", SH) + "\t\tl.pending = append(l.pending, j)\n", "\t\tif len(l.pending) == 0 {\n\t\t\tl.pending = append(l.pending, j)\n\t\t}\n", SH) m("MS a group's close is a word of the runner digest", "internal/shape/masked.go", "\t\tcase t.text == \")\":\n\t\t\t// A subshell's close: the command ends before it.\n\t\t\treturn j, true\n", "", SH) m("MS make is no build runner", "internal/shape/masked.go", @@ -112,9 +112,9 @@ m("MS the runner's arguments run to the end of the line", "internal/shape/shape.go", "\t\treturn !refusesRun(refusalsOf(c), toks[:i], toks[i+len(c):end])", "\t\treturn !refusesRun(refusalsOf(c), toks[:i], toks[i+len(c):])", SH) m("MS here-document bodies are read as commands", "internal/shape/tokenize.go", - "\t\t\t\tpending = append(pending, j)\n", "\t\t\t\t_ = j\n", SH) + "\t\tl.pending = append(l.pending, j)\n", "\t\t_ = j\n", SH) m("MS a comment is read as words in the list walk", "internal/shape/tokenize.go", - "\t\tcase docs && c == '#' && !w.started:", "\t\tcase false && docs && c == '#' && !w.started:", SH) + "\t\tcase docs && c == '#' && !l.w.started:", "\t\tcase false && docs && c == '#' && !l.w.started:", SH) m("MS the runner digest keeps a redirection's target", "internal/shape/masked.go", "\t\t\tj = operatorEnd(toks, j) + 1\n", "\t\t\tj = operatorEnd(toks, j)\n", SH) m("MS the runner digest runs to the end of the line", "internal/shape/masked.go", diff --git a/test/mutation/mutants/RES-9_token-bound.py b/test/mutation/mutants/RES-9_token-bound.py index 94bd8f5e..5e62b8b4 100644 --- a/test/mutation/mutants/RES-9_token-bound.py +++ b/test/mutation/mutants/RES-9_token-bound.py @@ -6,7 +6,7 @@ m("RES-9 the token bound is lifted", "internal/shape/tokenize.go", "\tmaxTokens = shapeBudget / bytesPerToken\n", "\tmaxTokens = 1 << 30\n", "TestRES9_") m("RES-9 the lexer reads on past the bound", "internal/shape/tokenize.go", - "\t\tif len(toks) > maxTokens {\n\t\t\t// Past the token bound: see bounded.\n", + "\t\tif len(l.toks) > maxTokens {\n\t\t\t// Past the token bound: see bounded.\n", "\t\tif false {\n\t\t\t// Past the token bound: see bounded.\n", "TestRES9_TokenBoundCapsShapeMemory") m("RES-9 the token past the bound is handed back", "internal/shape/tokenize.go", "\t\treturn toks[:maxTokens], errTokenBound\n", "\t\treturn toks, errTokenBound\n", "TestRES9_LexerStopsAtTheBound") @@ -14,4 +14,4 @@ "\tuncertain := perr == errUncertain || perr == errTokenBound\n", "\tuncertain := perr == errUncertain\n", "TestRES9_TokenBoundCapsShapeMemory") m("RES-9 the token slice is grown, not sized", "internal/shape/tokenize.go", - "\t\ttoks = make([]token, 0, tokenEstimate(s))\n", "\t\ttoks = []token(nil)\n", "TestRES9_TokenBoundCapsShapeMemory") + "\tl.toks = make([]token, 0, tokenEstimate(s))\n", "\tl.toks = []token(nil)\n", "TestRES9_TokenBoundCapsShapeMemory") From 7dd59e0e52726afd69911b33e2aa1c071d122458 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 12:48:35 -0700 Subject: [PATCH 10/50] shape: search the removed backslash-newlines instead of scanning them The masked-status walk reads a here-document's body line by line, and for each line joinedIn and delimLine walked every offset at which the joining removed a backslash-newline. A line of continuations followed by a body of short lines therefore took time in the product of the two: 160,000 of each, an 800 KB payload, took 13.8 s against the hook's 5 s timeout, and the hook cannot stop the work once it has started (CWE-407). It is the rubric's lld-6 class, work on a payload the read limit does not bound. The offsets ascend, since the joining appends them as it writes, so both readers now binary-search for the first offset past where they look and stop at the first past where they end. The same line takes 40 ms. The answers are unchanged: TestRubricLLD6_JoinedInKeepsItsBounds pins each edge of joinedIn and delimLine's three outcomes, and the timing test also checks that the runner after the body is still read and a continuation inside the body still gives the reading up. Three mutants restore either scan or move the search's edge. --- internal/shape/comsub.go | 27 ++++--- internal/shape/rubric_lld6_test.go | 70 +++++++++++++++++++ .../mutants/RUBRIC_lld-6_joins-search.py | 17 +++++ 3 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 internal/shape/rubric_lld6_test.go create mode 100644 test/mutation/mutants/RUBRIC_lld-6_joins-search.py diff --git a/internal/shape/comsub.go b/internal/shape/comsub.go index c2d1f758..39a847cf 100644 --- a/internal/shape/comsub.go +++ b/internal/shape/comsub.go @@ -1,6 +1,9 @@ package shape -import "strings" +import ( + "sort" + "strings" +) // A $( ) inside double quotes is a context of its own to the shell: quotes // inside it nest, parens count by depth, and the string around it goes on @@ -44,12 +47,16 @@ type joined struct { // joinedIn reports a backslash-newline removed between s[from] and s[to]: // just before a byte after from and no later than to. func (r joined) joinedIn(from, to int) bool { - for _, p := range r.joins { - if p > from && p <= to { - return true - } - } - return false + k := r.after(from) + return k < len(r.joins) && r.joins[k] <= to +} + +// after is the index in joins of the first offset past from. The offsets +// ascend, so it is searched for: a here-document's body is asked about line +// by line, and a scan of every offset for each line made a line of +// continuations and a body of short lines take time in their product. +func (r joined) after(from int) int { + return sort.SearchInts(r.joins, from+1) } // continuesAt reports a backslash-newline left at s[i] -- a continuation the @@ -545,9 +552,9 @@ func (r joined) delimLine(i, e int, d heredoc) (found, ok bool) { return l } from := i - for _, p := range r.joins { - if p < i || p > e { - continue + for _, p := range r.joins[r.after(i-1):] { + if p > e { + break } if strings.HasPrefix(line(r.s[from:p]), d.delim) { return false, false diff --git a/internal/shape/rubric_lld6_test.go b/internal/shape/rubric_lld6_test.go new file mode 100644 index 00000000..9256916f --- /dev/null +++ b/internal/shape/rubric_lld6_test.go @@ -0,0 +1,70 @@ +package shape + +import ( + "strings" + "testing" + "time" +) + +// TestRubricLLD6_JoinsAreSearchedNotScanned: the masked-status walk reads a +// here-document's body line by line, and asked of each line whether a +// backslash-newline was removed inside it by scanning every offset the +// joining kept, so a line of continuations followed by a body of short lines +// took time in the product of the two: 160,000 of each, 800 KB, took 13.8 s +// against the hook's 5 s timeout. The offsets are kept in order and are +// searched. The answers are unchanged: the line's runner is still found after +// the body, and a continuation inside the body still gives up the reading. +func TestRubricLLD6_JoinsAreSearchedNotScanned(t *testing.T) { + n := 160_000 + line := "echo " + strings.Repeat("a\\\n", n) + "; cat <&1 | tail" + in := bashInput(t, line) + var s Shape + start := time.Now() + s = Derive("Bash", in, benchKey) + if took := time.Since(start); took > 2*time.Second { + t.Errorf("an 800 KB line of continuations and a here-document took %v, want at most 2s", took) + } + if s.StatusMasked == nil || *s.StatusMasked != MaskedTest { + t.Errorf("status_masked %v, want %q: the runner after the body is read", show(s.StatusMasked), MaskedTest) + } + joinedBody := "echo a\\\nb; cat <&1 | tail" + if s := Derive("Bash", bashInput(t, joinedBody), benchKey); s.StatusMasked != nil { + t.Errorf("a continuation inside the body: status_masked %v, want null", show(s.StatusMasked)) + } +} + +// TestRubricLLD6_JoinedInKeepsItsBounds: the search answers what the scan +// did -- a join just before a byte after from and no later than to -- at each +// edge, and the delimiter line is still read across the joins inside it. +func TestRubricLLD6_JoinedInKeepsItsBounds(t *testing.T) { + r := joined{s: "0123456789", joins: []int{3, 5, 5}} + for _, tc := range []struct { + from, to int + want bool + }{ + {2, 3, true}, {3, 4, false}, {2, 2, false}, {3, 5, true}, {4, 5, true}, {5, 9, false}, {0, 9, true}, + } { + if got := r.joinedIn(tc.from, tc.to); got != tc.want { + t.Errorf("joinedIn(%d, %d) = %v, want %v", tc.from, tc.to, got, tc.want) + } + } + // The line s[2:5] is EOF. A join before the line is not the line's: read + // as one, its text would run backwards. One at the line's end ends a + // line that began EOF and ended in a backslash as written -- not the + // delimiter, and not certain -- and one inside it leaves a line, F, that + // is certainly not the delimiter. + d := heredoc{delim: "EOF"} + for _, tc := range []struct { + joins []int + found, ok bool + }{ + {[]int{1, 6}, true, true}, + {[]int{1, 5}, false, false}, + {[]int{1, 4}, false, true}, + } { + r = joined{s: "x\nEOF\ny", joins: tc.joins} + if found, ok := r.delimLine(2, 5, d); found != tc.found || ok != tc.ok { + t.Errorf("joins %v: found %v, ok %v; want %v, %v", tc.joins, found, ok, tc.found, tc.ok) + } + } +} diff --git a/test/mutation/mutants/RUBRIC_lld-6_joins-search.py b/test/mutation/mutants/RUBRIC_lld-6_joins-search.py new file mode 100644 index 00000000..da264fdb --- /dev/null +++ b/test/mutation/mutants/RUBRIC_lld-6_joins-search.py @@ -0,0 +1,17 @@ +# rubric lld-6 (fix list 29): the work done on a payload is bounded, not only +# the bytes read. The offsets at which backslash-newlines were removed are +# searched, not scanned: a scan per here-document body line made a line of +# continuations and a body of short lines quadratic (800 KB took 13.8 s). +# Scanning again in either reader, or searching from the wrong edge, is the +# regression. +m("RUBRIC lld-6 joinedIn scans every offset", "internal/shape/comsub.go", + "\tk := r.after(from)\n\treturn k < len(r.joins) && r.joins[k] <= to\n", + "\tfor _, p := range r.joins {\n\t\tif p > from && p <= to {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n", + "TestRubricLLD6_") +m("RUBRIC lld-6 delimLine scans every offset", "internal/shape/comsub.go", + "\tfor _, p := range r.joins[r.after(i-1):] {\n\t\tif p > e {\n\t\t\tbreak\n\t\t}\n", + "\tfor _, p := range r.joins {\n\t\tif p < i || p > e {\n\t\t\tcontinue\n\t\t}\n", + "TestRubricLLD6_") +m("RUBRIC lld-6 the search includes a join at from", "internal/shape/comsub.go", + "\treturn sort.SearchInts(r.joins, from+1)\n", "\treturn sort.SearchInts(r.joins, from)\n", + "TestRubricLLD6_") From c07bb000d0c7177f12814ddd409d5d5ea665089a Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 13:17:05 -0700 Subject: [PATCH 11/50] shape: bound every reading of a pathological line, not only its tokens The token bound (RES-9) held one 8 MiB shape, short words, and left the others where they were, measured on the hook binary against RES-29's 64 MiB: 87 MiB of RSS for a line of backslash-newlines, 84 for one word of $a, 173 for 8 MiB of empty lines and 178 for `ssh a` lines. Each came from a reading the bound does not reach. - A word's text is now the slice of the line it was read from while its bytes are the line's own and lie together, and is copied only when quote removal or an escape leaves a gap; a copy grows by doubling. Copied byte by byte, append growing a quarter at a time, one 8 MiB word cost five times its length in each of three readings. - joinContinuations keeps maxJoins (= maxTokens) offsets and records that it dropped the rest (joined.lost). Past the last kept offset the readers that ask where one was removed, a here-document body and a $( ) inside double quotes, give up, so the masked status is null there rather than a guess. One offset per continuation was 22 MiB per reading of 8 MiB of `a\`. The joined line is grown once to the line's length. - Derive passes the program search's tokens to the masked-status walk for its leading-cd count (P-12's fold of statusMasked's re-read), so the line is lexed three times rather than four. - Hosts reads ssh lines to the token bound between them, each line costing at least one: tokenized line by line, each was within the bound and the whole was not. It lowers the line once for both scheme lists, and host.inetAton turns a name away on its first byte instead of letting strconv build an error value for each part. - The command is decoded once into a buffer of its encoded size (jsonscan.DecodeString) and handed back without the string() copy; each of the hook's three readers of it paid five times its length before. Measured on the hook binary with 8 MiB payloads, peak RSS before -> after: continuations 86.9 -> 43.9, $a 83.6 -> 47.8, empty lines 172.9 -> 36.4, ssh lines 177.8 -> 46.7, escaped blanks 56.3 -> 53.0, here-document 58.2 -> 57.1, short words 48.6 -> 41.8. Every case exits 0. TestRES29_ShapeOfEveryPathologicalLineIsBounded holds Derive to 40 MiB and LeadingDirectory and Hosts to 24 MiB of allocation on sixteen 8 MiB shapes; TestRES29_ContinuationsPastTheKeptOffsets pins the joins bound at N and N+1; TestRES29_StringDecodesOnce pins DecodeString to Head's and encoding/json's decoding in one allocation. Eleven mutants, one per change, turn them red. Two existing mutants are re-anchored on the moved lines. --- internal/host/host.go | 23 +++- internal/jsonscan/res29_string_test.go | 47 +++++++ internal/jsonscan/unquote.go | 23 ++++ internal/shape/comsub.go | 15 +- internal/shape/field.go | 8 +- internal/shape/hosts.go | 31 +++-- internal/shape/masked.go | 8 +- internal/shape/res10_test.go | 12 +- internal/shape/res29_alloc_test.go | 129 ++++++++++++++++++ internal/shape/rubric_lld6_test.go | 11 +- internal/shape/shape.go | 62 ++++++--- internal/shape/tokenize.go | 95 +++++++++---- test/mutation/mutants/H-13_shape.py | 4 +- test/mutation/mutants/RES-29_shape-memory.py | 32 +++++ .../mutants/RUBRIC_lld-6_joins-search.py | 4 +- 15 files changed, 436 insertions(+), 68 deletions(-) create mode 100644 internal/jsonscan/res29_string_test.go create mode 100644 internal/shape/res29_alloc_test.go create mode 100644 test/mutation/mutants/RES-29_shape-memory.py diff --git a/internal/host/host.go b/internal/host/host.go index bd826a80..1aba47ed 100644 --- a/internal/host/host.go +++ b/internal/host/host.go @@ -151,7 +151,10 @@ func spell(a netip.Addr) string { // 10.1.2.3 and so is 167838211. Anything else is not an address and stays a // name -- 1.2.3.4.5, 256.1.1.1 and 08.1.2.3 are resolved, not parsed. func inetAton(h string) (netip.Addr, bool) { - parts := strings.Split(h, ".") + if !atonShaped(h) { + return netip.Addr{}, false + } + parts := strings.SplitN(h, ".", 5) if len(parts) > 4 { return netip.Addr{}, false } @@ -175,6 +178,24 @@ func inetAton(h string) (netip.Addr, bool) { return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)}), true } +// atonShaped reports whether h could be an inet_aton address at all: a digit +// first, and nothing but hex digits, x and dots. A name is turned away here +// without being split or parsed: each part strconv refused cost an error +// value, and an 8 MiB line of `https://a.b/` allocated 50 MiB of them. +func atonShaped(h string) bool { + if h == "" || h[0] < '0' || h[0] > '9' { + return false + } + for i := 0; i < len(h); i++ { + switch c := h[i]; { + case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F', c == 'x', c == 'X', c == '.': + default: + return false + } + } + return true +} + // atonPart reads one part of an inet_aton address: hex after 0x, octal after // a leading 0, decimal otherwise, at most 32 bits. func atonPart(p string) (uint64, bool) { diff --git a/internal/jsonscan/res29_string_test.go b/internal/jsonscan/res29_string_test.go new file mode 100644 index 00000000..5f280832 --- /dev/null +++ b/internal/jsonscan/res29_string_test.go @@ -0,0 +1,47 @@ +package jsonscan + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestRES29_StringDecodesOnce: DecodeString decodes a string span to what Head +// decodes it to -- escapes, surrogate pairs, an unpaired surrogate and +// invalid UTF-8 alike -- and does it in one allocation no larger than the +// encoded bytes. Head into a nil buffer and then string() made two copies of +// a command after growing the first from nothing: decoded by each of a +// hook's three readers of an 8 MiB command, that was most of the hook's RSS. +func TestRES29_StringDecodesOnce(t *testing.T) { + for _, raw := range []string{ + `""`, `"plain"`, `"a\"b\\c\/d\n\t\r\b\f"`, `"é€"`, `"😀"`, `"\ud83d x"`, + "\"\xff\xfe ok\"", `"caf` + "é" + `"`, + } { + buf := []byte(raw) + s := ValueSpan(buf) + want, _, err := Head(buf, s, 3*int(s.Len), nil) + if err != nil { + t.Fatalf("Head(%q): %v", raw, err) + } + got, err := DecodeString(buf, s) + if err != nil || got != string(want) { + t.Errorf("DecodeString(%q) = %q, %v; want %q", raw, got, err, want) + } + var std string + if json.Unmarshal(buf, &std) == nil && got != std { + t.Errorf("DecodeString(%q) = %q; encoding/json decodes %q", raw, got, std) + } + } + buf := []byte(`"` + strings.Repeat(`a\n`, 1<<16) + `"`) + s := ValueSpan(buf) + var out string + if n := testing.AllocsPerRun(10, func() { out, _ = DecodeString(buf, s) }); n != 1 { + t.Errorf("DecodeString allocated %v times, want once", n) + } + if len(out) != 2<<16 { + t.Fatalf("decoded %d bytes, want %d", len(out), 2<<16) + } + if _, err := DecodeString([]byte(`12`), ValueSpan([]byte(`12`))); err == nil { + t.Error("DecodeString of a number: no error") + } +} diff --git a/internal/jsonscan/unquote.go b/internal/jsonscan/unquote.go index 093dfdc9..99ccfd90 100644 --- a/internal/jsonscan/unquote.go +++ b/internal/jsonscan/unquote.go @@ -2,6 +2,7 @@ package jsonscan import ( "bytes" + "strings" "unicode/utf16" "unicode/utf8" ) @@ -160,6 +161,28 @@ func Head(buf []byte, s Span, max int, dst []byte) (out []byte, truncated bool, return out, truncated, nil } +// DecodeString decodes a whole string span. It allocates once, a buffer of the +// span's encoded length, which no valid string's decoding exceeds, and hands +// that buffer back as the string: Head into a buffer grown from nothing and +// then string() copied a command twice after growing the first copy a +// quarter at a time, and a hook decodes its command once per reader of it. +// Only invalid UTF-8, each byte of which decodes to three, grows the buffer. +func DecodeString(buf []byte, s Span) (string, error) { + b, err := body(buf, s) + if err != nil { + return "", err + } + var out strings.Builder + out.Grow(len(b)) + var tmp [utf8.UTFMax]byte + for r := 0; r < len(b); { + seg, next := segment(b, r, &tmp) + out.Write(seg) + r = next + } + return out.String(), nil +} + // Tail keeps the last max decoded bytes of a string span through a ring // buffer of size max while scanning forward, because a JSON string cannot be // decoded backwards. ring must hold max bytes; out aliases it and starts on a diff --git a/internal/shape/comsub.go b/internal/shape/comsub.go index 39a847cf..370c7ebc 100644 --- a/internal/shape/comsub.go +++ b/internal/shape/comsub.go @@ -42,13 +42,23 @@ const maxNest = 32 type joined struct { s string joins []int + // lost: more were removed than joins keeps (maxJoins), at or past its + // last offset, so from there on where one was is not known. + lost bool } // joinedIn reports a backslash-newline removed between s[from] and s[to]: // just before a byte after from and no later than to. func (r joined) joinedIn(from, to int) bool { k := r.after(from) - return k < len(r.joins) && r.joins[k] <= to + return k < len(r.joins) && r.joins[k] <= to || r.unkept(to) +} + +// unkept reports a span ending at to that may hold a removed backslash-newline +// joins did not keep: past maxJoins of them, every span that reaches the last +// kept offset may, and its readers give up. +func (r joined) unkept(to int) bool { + return r.lost && to >= r.joins[len(r.joins)-1] } // after is the index in joins of the first offset past from. The offsets @@ -551,6 +561,9 @@ func (r joined) delimLine(i, e int, d heredoc) (found, ok bool) { } return l } + if r.unkept(e) { + return false, false + } from := i for _, p := range r.joins[r.after(i-1):] { if p > e { diff --git a/internal/shape/field.go b/internal/shape/field.go index 988e0d8d..c1129360 100644 --- a/internal/shape/field.go +++ b/internal/shape/field.go @@ -27,12 +27,14 @@ func stringValue(raw json.RawMessage, s jsonscan.Span) (string, bool) { case jsonscan.Null: return "", true case jsonscan.String: - // An invalid byte decodes to three, so 3*Len bounds the decoded size. - out, _, err := jsonscan.Head(raw, s, 3*int(s.Len), nil) + // Decoded once into one buffer of the encoded size (DecodeString): + // a hook's readers of a command each decode it, and an 8 MiB one + // cost about five times itself each, grown from nothing and copied. + out, err := jsonscan.DecodeString(raw, s) if err != nil { return "", false } - return string(out), true + return out, true } return "", false } diff --git a/internal/shape/hosts.go b/internal/shape/hosts.go index 03b6c6af..55397685 100644 --- a/internal/shape/hosts.go +++ b/internal/shape/hosts.go @@ -59,11 +59,13 @@ func Hosts(toolName string, toolInput json.RawMessage) (wire []string, ssh []str if text == "" { return nil, nil } - ssh = collect(text, sshSchemes) + // One lowered copy serves both readings of the text: see collect. + lower := strings.ToLower(text) + ssh = collect(lower, sshSchemes) if verbForTool(toolName) == VerbExecute { ssh = mergeHosts(ssh, sshCommandHosts(text)) } - return collect(text, wireSchemes), ssh + return collect(lower, wireSchemes), ssh } // mergeHosts unions two host lists, sorted and de-duplicated, preserving the @@ -149,6 +151,12 @@ func takesValue(prog string, flag byte) bool { // A tokenizer error is not fatal here. tokenize returns the tokens it // completed along with the error, and a destination that appeared before an // unterminated quote is still a destination the command named. +// +// The lines are read to the token bound (RES-9) between them, each line +// costing at least one: tokenized one by one, each line was within the bound +// and the line as a whole was not, and 8 MiB of `ssh a` lines allocated +// 452 MiB. A host named past the bound is not read, as the program search +// reads no word past it. func sshCommandHosts(text string) []string { var out []string // A newline separates two commands, and the tokenizer treats it as @@ -156,13 +164,18 @@ func sshCommandHosts(text string) []string { // whole line and that count is its own contract. Splitting here rather // than changing the tokenizer keeps `ssh a` and `ssh b` on two lines from // collapsing into one invocation whose second host is never seen. - for _, line := range strings.Split(text, "\n") { - out = append(out, sshLineHosts(line)...) + for rest, budget := text, maxTokens; rest != "" && budget > 0; { + var line string + line, rest, _ = strings.Cut(rest, "\n") + hosts, read := sshLineHosts(line) + out = append(out, hosts...) + budget -= max(read, 1) } return out } -func sshLineHosts(line string) []string { +// sshLineHosts is sshCommandHosts over one line, and how many tokens it read. +func sshLineHosts(line string) ([]string, int) { // On a line it cannot finish, lex still returns the tokens it read before // the fault, and a host among them was named all the same. toks, meta, _ := tokenizeMarked(line) @@ -198,7 +211,7 @@ func sshLineHosts(line string) []string { out = append(out, destinations(prog, toks[i+1:end])...) i = end - 1 } - return out + return out, len(toks) } // destinations returns the hosts named by one ssh-family invocation's @@ -375,7 +388,8 @@ func destinationHost(tok string, requireRemote bool) (string, bool) { return host.Canonical(hostPart) } -// collect finds every hostname in text introduced by one of the given prefixes. +// collect finds every hostname introduced by one of the given prefixes in +// lower, a command line Hosts lowered once for both of its calls. // // The scan and the slice both read `lower`, and they have to be the same // string. strings.ToLower is NOT length-preserving in UTF-8 -- U+212A KELVIN @@ -396,9 +410,8 @@ func destinationHost(tok string, requireRemote bool) (string, bool) { // where a host ends: `=` and `!` are neither, so `https://api.example.com= // ghp_...!x/` came back as the host "api.example.com=ghp_...!x", a token // recorded in the field that holds the hostnames a call named. -func collect(text string, prefixes []string) []string { +func collect(lower string, prefixes []string) []string { seen := map[string]struct{}{} - lower := strings.ToLower(text) for _, p := range prefixes { from := 0 diff --git a/internal/shape/masked.go b/internal/shape/masked.go index b9f178ab..434458e8 100644 --- a/internal/shape/masked.go +++ b/internal/shape/masked.go @@ -98,7 +98,11 @@ var buildCommands = [][]string{ // `eval`, `trap`, `exec`, or a shell's `-c`, whose line is not looked into -- // it says nothing: null, never a kind and never "none". So does a line that // cannot be read to its end (tokenizeList, parseLine). -func statusMasked(cmd string, key []byte) (kind, runner *string) { +// +// held is how many of the line's leading cd steps the call's cwd digest +// holds (leadingSteps), which Derive counts from the program search's +// reading of the line rather than this function reading it again. +func statusMasked(cmd string, key []byte, held int) (kind, runner *string) { if controlByte(cmd) { return nil, nil } @@ -111,7 +115,7 @@ func statusMasked(cmd string, key []byte) (kind, runner *string) { if !ok { return nil, nil } - p.displace(leadingSteps(cmd)) + p.displace(held) var ( hidden []int diff --git a/internal/shape/res10_test.go b/internal/shape/res10_test.go index 57835893..5fa5c67a 100644 --- a/internal/shape/res10_test.go +++ b/internal/shape/res10_test.go @@ -13,10 +13,10 @@ import ( // digest is null rather than one that pairs with a run somewhere else. func TestRES10_RunnerPastTheFoldIsNotPaired(t *testing.T) { line := func(n int) string { return strings.Repeat("cd a && ", n) + "make test" } - if got := leadingSteps(line(MaxLeadingCd)); got != MaxLeadingCd { + if got := leadingSteps(dirsOf(line(MaxLeadingCd))); got != MaxLeadingCd { t.Errorf("%d steps: leadingSteps %d", MaxLeadingCd, got) } - if got := leadingSteps(line(MaxLeadingCd + 1)); got != 0 { + if got := leadingSteps(dirsOf(line(MaxLeadingCd + 1))); got != 0 { t.Errorf("%d steps: leadingSteps %d, want 0: past the bound no step is held", MaxLeadingCd+1, got) } if s := Derive("Bash", bashInput(t, line(MaxLeadingCd)), benchKey); s.RunnerDigest == nil { @@ -50,7 +50,7 @@ func TestRES10_LeadingStepsPastTheTokenBound(t *testing.T) { if _, err := tokenizeProgram(heredoc); err != errTokenBound { t.Fatalf("the here-document line reads with %v, want errTokenBound", err) } - if got := leadingSteps(heredoc); got != 1 { + if got := leadingSteps(dirsOf(heredoc)); got != 1 { t.Errorf("one cd before a long here-document: leadingSteps %d, want 1", got) } dirs, ok := leadingDirectories(strings.Repeat("cd a && ", 100) + long) @@ -58,3 +58,9 @@ func TestRES10_LeadingStepsPastTheTokenBound(t *testing.T) { t.Errorf("100 cds before 50,000 words: %d directories, %v; want %d, true", len(dirs), ok, MaxLeadingCd+1) } } + +// dirsOf is a line's leading directories, as Derive reads them. +func dirsOf(line string) []string { + dirs, _ := leadingDirectories(line) + return dirs +} diff --git a/internal/shape/res29_alloc_test.go b/internal/shape/res29_alloc_test.go new file mode 100644 index 00000000..6dcae035 --- /dev/null +++ b/internal/shape/res29_alloc_test.go @@ -0,0 +1,129 @@ +package shape + +import ( + "bytes" + "encoding/json" + "runtime" + "strings" + "testing" +) + +// pathologicalLines is the tool input of a Bash call whose line is one of +// each shape that has driven the shape of a call past its memory: one unit +// repeated, behind echo where the unit is not a command of its own, as many +// times as an 8 MiB hook payload holds. The input is encoded as Claude Code +// encodes it, without escaping &, < and > as \u0026 and the like. +func pathologicalLines(t testing.TB) []struct { + name string + in json.RawMessage +} { + encode := func(line string) []byte { + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + if err := enc.Encode(map[string]string{"command": line}); err != nil { + t.Fatal(err) + } + return bytes.TrimSuffix(b.Bytes(), []byte("\n")) + } + fill := func(prefix, unit, suffix string) json.RawMessage { + per := len(encode(unit)) - len(encode("")) + n := (8<<20 - len(encode(prefix+suffix))) / per + return encode(prefix + strings.Repeat(unit, n) + suffix) + } + return []struct { + name string + in json.RawMessage + }{ + {"short words", fill("echo", " a", "")}, + {"backslash-newlines", fill("echo ", "a\\\n", "")}, + {"one word of $a", fill("echo ", "$a", "")}, + {"escaped blanks", fill("echo ", "\\ ", "")}, + {"quoted blanks", fill("echo ", "'' ", "")}, + {"double-quoted blanks", fill("echo ", `"" `, "")}, + {"one double-quoted word", fill("echo \"", "a", "\"")}, + {"redirections", fill("", "<", "")}, + {"cd a&&", fill("", "cd a&&", "")}, + {"a here-document", fill("python3 - <<'E'\n", "w1 w2 w3 w4 w5 w6 w7 w8 w9 w0\n", "E\n")}, + {"substitutions", fill("echo ", "$(a) ", "")}, + {"assignments", fill("", "X_TOKEN=abc ", "")}, + {"empty lines", fill("echo", "\n", "")}, + {"short lines", fill("echo", "\na", "")}, + {"ssh lines", fill("", "ssh a\n", "")}, + {"urls", fill("curl", " https://a.b/", "")}, + } +} + +// allocated is how many bytes f allocates. +func allocated(f func()) uint64 { + var m0, m1 runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&m0) + f() + runtime.ReadMemStats(&m1) + return m1.TotalAlloc - m0.TotalAlloc +} + +// TestRES29_ShapeOfEveryPathologicalLineIsBounded: each of the hook's +// readings of a Bash call's line -- Derive, LeadingDirectory and Hosts -- +// allocates a few times the payload at most, whatever the 8 MiB line is made +// of: Derive 40 MiB, the others 24, each including one decoding of the +// command. The token bound (RES-9) held one shape, short words, and no +// other. 8 MiB of backslash-newlines allocated 322 MiB in Derive, keeping one +// offset per continuation and joining the line three times. One 8 MiB word +// of $a allocated 175 MiB, copied byte by byte into a buffer that grew by a +// quarter at a time. Hosts tokenized each line of a line of lines within the +// bound and the whole past it, 452 MiB for `ssh a` lines, and every name +// after https:// cost a parse error. And each reader decoded the command +// into a buffer grown from nothing and copied it again. With the hook's own +// intake those drove a hook to 87, 84 and 178 MiB of RSS against RES-29's +// 64; every shape here now peaks under 58. +func TestRES29_ShapeOfEveryPathologicalLineIsBounded(t *testing.T) { + for _, tc := range pathologicalLines(t) { + if len(tc.in) > 8<<20 { + t.Fatalf("%s: the input is %d bytes, past an 8 MiB payload", tc.name, len(tc.in)) + } + for _, r := range []struct { + name string + read func() + bound uint64 + }{ + {"Derive", func() { Derive("Bash", tc.in, benchKey) }, 40 << 20}, + {"LeadingDirectory", func() { LeadingDirectory("Bash", tc.in) }, 24 << 20}, + {"Hosts", func() { Hosts("Bash", tc.in) }, 24 << 20}, + } { + if alloc := allocated(r.read); alloc > r.bound { + t.Errorf("%s: %s allocated %d MiB, want at most %d", tc.name, r.name, alloc>>20, r.bound>>20) + } + } + } +} + +// TestRES29_ContinuationsPastTheKeptOffsets: joinContinuations keeps +// maxJoins offsets and no more. Up to them a here-document after the +// continuations is read and the runner after its body found; one past them +// where a backslash-newline was removed is no longer known, and the reading +// of the body gives up, so the masked status is null rather than a guess. +// The program is named either way. +func TestRES29_ContinuationsPastTheKeptOffsets(t *testing.T) { + line := func(n int) string { + return "echo " + strings.Repeat("a\\\n", n) + "; cat <&1 | tail" + } + if r := joinContinuations(line(maxJoins)); r.lost || len(r.joins) != maxJoins { + t.Errorf("%d continuations: %d kept, lost %v; want all kept", maxJoins, len(r.joins), r.lost) + } + if r := joinContinuations(line(maxJoins + 1)); !r.lost || len(r.joins) != maxJoins { + t.Errorf("%d continuations: %d kept, lost %v; want %d kept and the rest lost", maxJoins+1, len(r.joins), r.lost, maxJoins) + } + s := Derive("Bash", bashInput(t, line(maxJoins)), benchKey) + if s.StatusMasked == nil || *s.StatusMasked != MaskedTest { + t.Errorf("at maxJoins: status_masked %v, want %q", show(s.StatusMasked), MaskedTest) + } + s = Derive("Bash", bashInput(t, line(maxJoins+1)), benchKey) + if s.StatusMasked != nil { + t.Errorf("one past maxJoins: status_masked %v, want null", show(s.StatusMasked)) + } + if s.Program == nil || *s.Program != "echo" { + t.Errorf("one past maxJoins: program %v, want echo", show(s.Program)) + } +} diff --git a/internal/shape/rubric_lld6_test.go b/internal/shape/rubric_lld6_test.go index 9256916f..2ae1ceef 100644 --- a/internal/shape/rubric_lld6_test.go +++ b/internal/shape/rubric_lld6_test.go @@ -14,15 +14,20 @@ import ( // against the hook's 5 s timeout. The offsets are kept in order and are // searched. The answers are unchanged: the line's runner is still found after // the body, and a continuation inside the body still gives up the reading. +// The line holds fewer continuations than maxJoins, so every offset is kept +// and the body is read. func TestRubricLLD6_JoinsAreSearchedNotScanned(t *testing.T) { - n := 160_000 - line := "echo " + strings.Repeat("a\\\n", n) + "; cat <&1 | tail" + joins, lines := 40_000, 400_000 + if joins >= maxJoins { + t.Fatalf("%d continuations: past maxJoins, %d, the body would not be read", joins, maxJoins) + } + line := "echo " + strings.Repeat("a\\\n", joins) + "; cat <&1 | tail" in := bashInput(t, line) var s Shape start := time.Now() s = Derive("Bash", in, benchKey) if took := time.Since(start); took > 2*time.Second { - t.Errorf("an 800 KB line of continuations and a here-document took %v, want at most 2s", took) + t.Errorf("a 920 KB line of continuations and a here-document took %v, want at most 2s", took) } if s.StatusMasked == nil || *s.StatusMasked != MaskedTest { t.Errorf("status_masked %v, want %q: the runner after the body is read", show(s.StatusMasked), MaskedTest) diff --git a/internal/shape/shape.go b/internal/shape/shape.go index 460d9f84..0aebaf95 100644 --- a/internal/shape/shape.go +++ b/internal/shape/shape.go @@ -169,7 +169,9 @@ func Derive(toolName string, toolInput json.RawMessage, key []byte) Shape { n := len(dropLeadingAssignments(toks)) s.Argc = &n } - s.StatusMasked, s.RunnerDigest = statusMasked(cmd, key) + // Where the line cannot be read, dirs is empty and no step is folded. + dirs, _ := leadingRun(pshaped, perr) + s.StatusMasked, s.RunnerDigest = statusMasked(cmd, key, leadingSteps(dirs)) return s } @@ -486,12 +488,11 @@ const MaxLeadingCd = 64 const _ = uint(maxTokens - 3*(MaxLeadingCd+1)) // leadingSteps is how many leading `cd DIR &&` steps LeadingDirectory folds -// onto a call's cwd for the line cmd: the cds on it that the call's cwd -// digest already holds. None past MaxLeadingCd, where the hook records the -// directory as unknown and the digest holds none of them. -func leadingSteps(cmd string) int { - // Where the line cannot be read, dirs is empty and no step is folded. - dirs, _ := leadingDirectories(cmd) +// onto a call's cwd for a line whose leading directories are dirs: the cds +// on it that the call's cwd digest already holds. None past MaxLeadingCd, +// where the hook records the directory as unknown and the digest holds none +// of them. +func leadingSteps(dirs []string) int { if len(dirs) > MaxLeadingCd { return 0 } @@ -508,7 +509,12 @@ func leadingSteps(cmd string) int { // away from. Any other error is an uncertain reading, which may begin inside // the run, and reports false. func leadingDirectories(cmd string) ([]string, bool) { - toks, err := tokenizeProgram(cmd) + return leadingRun(tokenizeProgram(cmd)) +} + +// leadingRun is leadingDirectories over the program search's reading of the +// line, toks and err, which Derive has already made. +func leadingRun(toks []token, err error) ([]string, bool) { if err != nil && err != errTokenBound { return nil, false } @@ -809,25 +815,41 @@ func inGroup(open []byte, t token) []byte { // joinContinuations removes every backslash-newline the shell removes before // it splits a line into words: outside quotes and inside double quotes, but // not inside single quotes, where a backslash is literal. It returns the -// offsets in the result at which it removed one, ascending: the byte there is -// the one that followed it. Its quote tracking is flat, which is right outside -// a $( ) and may be wrong inside one; see comsubEnd. -func joinContinuations(s string) (string, []int) { +// line with them removed and the offsets in it at which it removed one, +// ascending: the byte there is the one that followed it. Its quote tracking +// is flat, which is right outside a $( ) and may be wrong inside one; see +// comsubEnd. +// +// It keeps the first maxJoins offsets and no more (joined.lost): one per +// continuation, 8 MiB of `a\` kept 2.8 million of them, 22 MiB, +// in each reading of the line. The line itself is joined whole. +func joinContinuations(s string) joined { return joinLines(s, false) } +// maxJoins is how many offsets joinContinuations keeps: as many as the +// token bound lets a line have tokens (RES-9), 340 KB of them. Past them the +// readers that ask where a backslash-newline was removed, a here-document's +// body and a $( ) inside double quotes, give up rather than guess. +const maxJoins = maxTokens + // joinLines is joinContinuations; with comments, an unquoted # at the start // of a word runs to the end of its line, as a comment does to the shell, and // a backslash at its end continues nothing: `# run the tests \` and then // `make test | tail` on the next line runs make. -func joinLines(s string, comments bool) (string, []int) { - if !strings.Contains(s, "\\\n") { - return s, nil +func joinLines(s string, comments bool) joined { + n := strings.Count(s, "\\\n") + if n == 0 { + return joined{s: s} } var ( b strings.Builder - joins []int + joins = make([]int, 0, min(n, maxJoins)) + lost bool ) + // The line loses bytes, except where a comment or a single quote keeps + // a backslash-newline, so it is grown once to the line's length. + b.Grow(len(s)) inSingle, inDouble := false, false for i := 0; i < len(s); i++ { c := s[i] @@ -844,7 +866,11 @@ func joinLines(s string, comments bool) (string, []int) { } case c == '\\' && i+1 < len(s): if s[i+1] == '\n' { - joins = append(joins, b.Len()) + if len(joins) < maxJoins { + joins = append(joins, b.Len()) + } else { + lost = true + } i++ continue } @@ -858,7 +884,7 @@ func joinLines(s string, comments bool) (string, []int) { } b.WriteByte(c) } - return b.String(), joins + return joined{s: b.String(), joins: joins, lost: lost} } // wordStart reports whether a word would begin after the text written so diff --git a/internal/shape/tokenize.go b/internal/shape/tokenize.go index 5ff9d120..5e9eaba1 100644 --- a/internal/shape/tokenize.go +++ b/internal/shape/tokenize.go @@ -114,8 +114,14 @@ type token struct { // The bytes between words -- whether a blank or a newline came before the next // one -- are not a word's, and stay with the lexer. type word struct { - text strings.Builder - started bool + // The word's text is the slice s[from:to] of the line while every byte + // of it is the line's own and they lie together -- a plain word, which + // most are -- and is copied to text only once quote removal or an + // escape leaves a gap (add). copied says which. + text strings.Builder + from, to int + copied bool + started bool // glued: the word began with no blank or newline before it (token.glued). glued bool quotedAt int // token.quotedAt: -1 until quoting touches the word @@ -129,6 +135,46 @@ func newWord() word { return word{quotedAt: -1} } // reset makes w the state before the next word's first byte. func (w *word) reset() { *w = newWord() } +// add appends s[from:to], bytes of the line s as they are written, to the +// word's text. Where they follow the slice the word already is, the slice +// grows and nothing is copied: 8 MiB of $a is one word, copied byte by byte +// it allocated five times its length in each of three readings of the line. +// A copy grows by doubling (strings.Builder.Grow), where append's growth of +// a large buffer is a quarter at a time. +func (w *word) add(s string, from, to int) { + switch { + case w.copied: + case w.from == w.to: + w.from, w.to = from, to + return + case w.to == from: + w.to = to + return + default: + w.copied = true + w.text.Grow(w.to - w.from + to - from) + w.text.WriteString(s[w.from:w.to]) + } + w.text.Grow(to - from) + w.text.WriteString(s[from:to]) +} + +// length is how many bytes of text the word has. +func (w *word) length() int { + if w.copied { + return w.text.Len() + } + return w.to - w.from +} + +// string is the word's text, over the line s. +func (w *word) string(s string) string { + if w.copied { + return w.text.String() + } + return s[w.from:w.to] +} + // tokenizeMarked is tokenize plus one bit per token: whether the tokenizer // emitted it for an unquoted metacharacter. // @@ -150,7 +196,7 @@ func tokenizeMarked(s string) ([]string, []bool, error) { // tokenizeShape splits a command line as tokenize does and reports, per // token, what the text cannot: see token. func tokenizeShape(s string) ([]token, error) { - return bounded(lex(s, false, false, nil)) + return bounded(lex(joined{s: s}, false, false)) } // tokenizeProgram is tokenizeShape for the program search, which reads one @@ -173,8 +219,7 @@ func tokenizeShape(s string) ([]token, error) { // Where it cannot tell how the shell reads on, it stops with errUncertain // rather than guess: see lex. func tokenizeProgram(s string) ([]token, error) { - j, joins := joinContinuations(s) - return bounded(lex(j, true, false, joins)) + return bounded(lex(joinContinuations(s), true, false)) } // tokenizeList is tokenizeProgram for the walk over a line's whole list of @@ -196,8 +241,7 @@ func tokenizeProgram(s string) ([]token, error) { // The program search keeps its own reading, which stops at a here-document // instead: what it names is unchanged. func tokenizeList(s string) ([]token, error) { - j, joins := joinLines(s, true) - return bounded(lex(j, true, true, joins)) + return bounded(lex(joinLines(s, true), true, true)) } // lex is tokenizeShape and tokenizeProgram; program selects the second's @@ -210,12 +254,15 @@ func tokenizeList(s string) ([]token, error) { // does not parse, whose quoting it may have misread. // - a $( ) inside double quotes whose close comsubEnd cannot find for // certain, or whose $ is the second of an even run, as in "$$(": bash -// reads $$ and then a (, and the $( ) only a misreading. joins: the -// offsets at which backslash-newlines were removed from s. +// reads $$ and then a (, and the $( ) only a misreading. // -// docs selects tokenizeList's reading of here-documents and comments. -func lex(s string, program, docs bool, joins []int) ([]token, error) { - l := &lexer{s: s, program: program, docs: docs, joins: joins, w: newWord(), dollarAt: -1} +// r is the line, with the offsets at which backslash-newlines were removed +// from it (joinContinuations); none for tokenizeShape, which reads each as +// a continuation where it stands. docs selects tokenizeList's reading of +// here-documents and comments. +func lex(r joined, program, docs bool) ([]token, error) { + s := r.s + l := &lexer{s: s, r: r, program: program, docs: docs, w: newWord(), dollarAt: -1} l.toks = make([]token, 0, tokenEstimate(s)) for i := 0; i < len(s); i++ { if len(l.toks) > maxTokens { @@ -259,7 +306,7 @@ func lex(s string, program, docs bool, joins []int) ([]token, error) { type lexer struct { s string program, docs bool - joins []int + r joined toks []token w word sawNL bool @@ -290,7 +337,7 @@ func (l *lexer) begin() { // markQuoted records where quoting first touched the current token. func (l *lexer) markQuoted() { if w := &l.w; w.quotedAt < 0 { - w.quotedAt = w.text.Len() + w.quotedAt = w.length() } } @@ -326,7 +373,7 @@ func (l *lexer) unterminated() error { // flush emits the current word, if one was started. func (l *lexer) flush() { if w := &l.w; w.started { - l.toks = append(l.toks, token{text: w.text.String(), quotedAt: w.quotedAt, opaque: w.opaque, nlBefore: l.sawNL, glued: w.glued, ticks: w.ticks}) + l.toks = append(l.toks, token{text: w.string(l.s), quotedAt: w.quotedAt, opaque: w.opaque, nlBefore: l.sawNL, glued: w.glued, ticks: w.ticks}) l.expanded = l.expanded || w.opaque l.sawNL, l.gap = false, false w.reset() @@ -354,7 +401,7 @@ func (l *lexer) blank(i int) (int, error) { } bodies = append(bodies, d) } - e := joined{l.s, l.joins}.bodiesEnd(i+1, bodies) + e := l.r.bodiesEnd(i+1, bodies) if e < 0 { return i, errUncertain } @@ -382,7 +429,7 @@ func (l *lexer) backslash(i int) (int, error) { if l.s[i] != '\n' { // a backslash-newline is a line continuation l.begin() l.markQuoted() - l.w.text.WriteByte(l.s[i]) + l.w.add(l.s, i, i+1) } return i, nil } @@ -405,7 +452,7 @@ func (l *lexer) single(i int) (int, error) { } l.begin() l.markQuoted() - l.w.text.WriteString(s[i+1 : i+1+j]) + l.w.add(s, i+1, i+1+j) return i + j + 1, nil } @@ -428,14 +475,14 @@ func (l *lexer) double(i int) (int, error) { if s[i] == '\\' && i+1 < len(s) { switch n := s[i+1]; n { case '"', '\\', '$', '`': - w.text.WriteByte(n) + w.add(s, i+1, i+2) i++ continue case '\n': i++ continue } - w.text.WriteByte('\\') + w.add(s, i, i+1) continue } if s[i] == '"' { @@ -447,13 +494,13 @@ func (l *lexer) double(i int) (int, error) { // string goes on after the ) that closes it. end := -1 if dq%2 == 1 { - end = joined{s, l.joins}.substEnd(i, 0) + end = l.r.substEnd(i, 0) } if end < 0 { return i, errUncertain } w.opaque = true - w.text.WriteString(s[i : end+1]) + w.add(s, i, end+1) i = end continue } @@ -463,7 +510,7 @@ func (l *lexer) double(i int) (int, error) { if s[i] == '`' { w.ticks++ } - w.text.WriteByte(s[i]) + w.add(s, i, i+1) } if !closed { return i, l.unterminated() @@ -517,7 +564,7 @@ func (l *lexer) plain(i int) { l.grouped = true } l.begin() - l.w.text.WriteByte(c) + l.w.add(l.s, i, i+1) } // tokenEstimate is how many tokens lex will emit from s, for the common line diff --git a/test/mutation/mutants/H-13_shape.py b/test/mutation/mutants/H-13_shape.py index e65406f0..078b9f8f 100644 --- a/test/mutation/mutants/H-13_shape.py +++ b/test/mutation/mutants/H-13_shape.py @@ -10,7 +10,7 @@ "\tif l.expands(i, false) {\n\t\tl.w.opaque = true\n\t}", "\tif l.expands(i, false) {\n\t\t_ = l.w.opaque\n\t}", "TestNoCorpusLineLeaksIntoProgram") m("H-13 backslash-newlines are not joined before the program search", "internal/shape/shape.go", - "\tif !strings.Contains(s, \"\\\\\\n\") {\n\t\treturn s, nil\n\t}", "\tif true {\n\t\treturn s, nil\n\t}", + "\tif n == 0 {\n\t\treturn joined{s: s}\n\t}", "\tif true {\n\t\treturn joined{s: s}\n\t}", "TestNoCorpusLineLeaksIntoProgram|TestTokenizeProgram") m("H-13 a paren where a redirect target belongs is read as a subshell", "internal/shape/shape.go", "\tif toks[i+1].meta {\n\t\t// A paren where the target belongs", @@ -180,7 +180,7 @@ m("H-13 a backslash-newline the joining left is read past in its double quotes", C, "\t\t\tif r.continuesAt(i) {", "\t\t\tif false {", TPROG) m("H-13 the joining's offsets are not kept", "internal/shape/shape.go", - "\t\t\t\tjoins = append(joins, b.Len())\n", "", TPROG) + "\t\t\t\t\tjoins = append(joins, b.Len())\n", "", TPROG) m("H-13 a quoted span joined across a newline opens a here-document's body", C, " || r.joinedIn(from, to)))", "))", TPROG) m("H-13 an unquoted here-document's body is read across backslash-newlines", C, diff --git a/test/mutation/mutants/RES-29_shape-memory.py b/test/mutation/mutants/RES-29_shape-memory.py new file mode 100644 index 00000000..d69b7fa9 --- /dev/null +++ b/test/mutation/mutants/RES-29_shape-memory.py @@ -0,0 +1,32 @@ +# RES-29: a pathological 8 MiB line keeps every reading of it -- Derive, +# LeadingDirectory, Hosts -- to a few times the payload. Copying every word, +# growing a copy a quarter at a time, keeping an offset per continuation, +# forgetting that offsets were dropped, reading lines past the token bound, +# parsing every name as a number, lowering the line twice, decoding the +# command into a grown buffer, or reading the line again for the leading cds +# is each the regression. +m("RES-29 every word is copied", "internal/shape/tokenize.go", + "\tcase w.from == w.to:\n\t\tw.from, w.to = from, to\n\t\treturn\n\tcase w.to == from:\n\t\tw.to = to\n\t\treturn\n", + "", "TestRES29_") +m("RES-29 a copied word grows a quarter at a time", "internal/shape/tokenize.go", + "\tw.text.Grow(to - from)\n\tw.text.WriteString(s[from:to])\n", + "\tw.text.WriteString(s[from:to])\n", "TestRES29_") +m("RES-29 every removed backslash-newline is kept", "internal/shape/shape.go", + "\t\t\t\tif len(joins) < maxJoins {\n", "\t\t\t\tif true {\n", "TestRES29_") +m("RES-29 dropped offsets are forgotten", "internal/shape/shape.go", + "\t\t\t\t} else {\n\t\t\t\t\tlost = true\n\t\t\t\t}\n", "\t\t\t\t}\n", "TestRES29_") +m("RES-29 a span past the kept offsets is read as certain", "internal/shape/comsub.go", + "\treturn r.lost && to >= r.joins[len(r.joins)-1]\n", "\treturn false\n", "TestRES29_") +m("RES-29 the leading cds are read again for the masked status", "internal/shape/shape.go", + "\tdirs, _ := leadingRun(pshaped, perr)\n", "\tdirs, _ := leadingDirectories(cmd)\n", "TestRES29_") +m("RES-29 ssh lines are read past the token bound", "internal/shape/hosts.go", + "\tfor rest, budget := text, maxTokens; rest != \"\" && budget > 0; {\n", + "\tfor rest, budget := text, maxTokens; rest != \"\"; {\n", "TestRES29_") +m("RES-29 an empty line costs the ssh reading nothing", "internal/shape/hosts.go", + "\t\tbudget -= max(read, 1)\n", "\t\tbudget -= read\n", "TestRES29_") +m("RES-29 every name is parsed as an inet_aton address", "internal/host/host.go", + "\tif !atonShaped(h) {\n\t\treturn netip.Addr{}, false\n\t}\n", "", "TestRES29_") +m("RES-29 the line is lowered for each scheme list", "internal/shape/hosts.go", + "\tssh = collect(lower, sshSchemes)\n", "\tssh = collect(strings.ToLower(text), sshSchemes)\n", "TestRES29_") +m("RES-29 the command is decoded into a buffer grown from nothing", "internal/jsonscan/unquote.go", + "\tvar out strings.Builder\n\tout.Grow(len(b))\n", "\tvar out strings.Builder\n", "TestRES29_") diff --git a/test/mutation/mutants/RUBRIC_lld-6_joins-search.py b/test/mutation/mutants/RUBRIC_lld-6_joins-search.py index da264fdb..568c0f45 100644 --- a/test/mutation/mutants/RUBRIC_lld-6_joins-search.py +++ b/test/mutation/mutants/RUBRIC_lld-6_joins-search.py @@ -5,8 +5,8 @@ # Scanning again in either reader, or searching from the wrong edge, is the # regression. m("RUBRIC lld-6 joinedIn scans every offset", "internal/shape/comsub.go", - "\tk := r.after(from)\n\treturn k < len(r.joins) && r.joins[k] <= to\n", - "\tfor _, p := range r.joins {\n\t\tif p > from && p <= to {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n", + "\tk := r.after(from)\n\treturn k < len(r.joins) && r.joins[k] <= to || r.unkept(to)\n", + "\tfor _, p := range r.joins {\n\t\tif p > from && p <= to {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn r.unkept(to)\n", "TestRubricLLD6_") m("RUBRIC lld-6 delimLine scans every offset", "internal/shape/comsub.go", "\tfor _, p := range r.joins[r.after(i-1):] {\n\t\tif p > e {\n\t\t\tbreak\n\t\t}\n", From cd27c9b410208feb202d08b50f2ec8c262327526 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 13:21:56 -0700 Subject: [PATCH 12/50] shape: restate the token bound from Derive's measured cost per token The bound is shapeBudget over Derive's allocation per token on the 4 KB line, and the previous commit moved that allocation: a plain word is now a slice of the line rather than a copy, the leading-cd count reads the program search's tokens rather than lexing the line a fourth time, and the command is decoded once into a buffer of its size. Measured as the rows are recorded (best of three, -benchtime 20x, go1.27.2, Apple M3): BenchmarkDerive/bash_long_4k 421,153 B, 2,301 allocs -> 285,328 B, 229 BenchmarkDerive/bash_short 3,280 B, 59 allocs -> 2,656 B, 33 BenchmarkLexTokens 112,848 B, 741 allocs -> 107,376 B, 90 BenchmarkRunHook/bash_short 53,828 B, 361 allocs -> 50,612 B, 301 BenchmarkRunPost/bash_short 47,638 B, 298 allocs -> 45,728 B, 260 bytesPerToken is 285,328 over 1,068 tokens, 267.2, rounded up to 268, and maxTokens 62,601 where it was 42,473; TestRES9_BoundMatchesItsArithmetic failed on the restated row until the constant followed it. The hook and post entries' alloc_bytes_max follow their benchmarks, and internal/budget/budgets_gen.go is regenerated from the file. tokenEstimate now takes an escaped byte as part of the word: counted as a separator, 8 MiB of `\ ` -- one word -- was given a token slice for the whole bound in each reading, and with the larger bound Derive's allocation on it reached the RES-29 test's 40 MiB. The RES-10 tests that read past the bound now build their lines from maxTokens in the shape package and from 200,000 words, three times the bound, in the hook's. Two mutants anchored on the restated rows are re-anchored, and one more turns the estimate's escape back into a separator. --- internal/budget/budgets_gen.go | 2 +- internal/hook/res10_test.go | 14 ++++---- internal/shape/res10_test.go | 9 +++--- internal/shape/tokenize.go | 34 ++++++++++++-------- test/mutation/mutants/RES-29_shape-memory.py | 3 ++ test/mutation/mutants/RES-36_budgets-file.py | 4 +-- test/perf/budgets.json | 4 +-- 7 files changed, 42 insertions(+), 28 deletions(-) diff --git a/internal/budget/budgets_gen.go b/internal/budget/budgets_gen.go index 09fbea70..89145218 100644 --- a/internal/budget/budgets_gen.go +++ b/internal/budget/budgets_gen.go @@ -3,4 +3,4 @@ package budget // tableJSON is test/perf/budgets.json, compacted; Table decodes it on first use. -const tableJSON = "{\"budgets_version\":1,\"go_version\":\"go1.27.3\",\"benchmark_rows\":\"the measured best of three on the reference machine (darwin/arm64, go1.27.3, -benchtime 20x, 2026-10-22); a row's max equals its measurement, and the gate's ten percent (scripts/benchgate) is the only margin\",\"benchmark_platform\":\"darwin/arm64\",\"platforms\":{\"darwin/arm64\":{\"reference\":\"Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \\\"-s -w\\\")\",\"set_from\":\"measured 2026-10-13 on the P-06 tree (archive 85.1.2)\",\"tolerance\":0.15,\"tolerance_until\":\"2026-11-06\",\"tolerance_after\":0.1,\"entries\":{},\"case_budgets\":{}},\"linux/amd64\":{\"reference\":\"ubuntu-latest\",\"set_from\":\"measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on d2071b6 by the measure-linux-benchmarks job, run 39762603829 (best of three, -benchtime 20x)\",\"tolerance\":0.1,\"tolerance_until\":\"\",\"tolerance_after\":0.1,\"entries\":{\"hook.bash_short\":{\"p50\":7.71,\"p95\":7.71},\"post.bash_short\":{\"p50\":7.65,\"p95\":7.65},\"hook.edit_50k\":{\"p50\":9.67,\"p95\":9.67},\"post.edit_314k\":{\"p50\":7.83,\"p95\":7.83},\"recap.stop\":{\"p50\":8.07,\"p95\":8.07},\"recap.pending\":{\"p50\":7.93,\"p95\":7.93},\"probe.start\":{\"p50\":7.64,\"p95\":7.64},\"probe.end\":{\"p50\":7.66,\"p95\":7.66}},\"case_budgets\":{\"hook.400B\":7.64,\"hook.40KB\":7.7,\"post.540B\":7.65,\"post.40KB\":7.65},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":4624,\"measured_allocs_per_op\":71,\"bytes_per_op_max\":4624,\"allocs_per_op_max\":71},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":430689,\"measured_allocs_per_op\":2333,\"bytes_per_op_max\":430689,\"allocs_per_op_max\":2333},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":16550,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":16550,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1885613,\"measured_allocs_per_op\":6829,\"bytes_per_op_max\":1885613,\"allocs_per_op_max\":6829},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8417557,\"measured_allocs_per_op\":31710,\"bytes_per_op_max\":8417557,\"allocs_per_op_max\":31710},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36038866,\"measured_allocs_per_op\":122027,\"bytes_per_op_max\":36038866,\"allocs_per_op_max\":122027},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235463295,\"measured_allocs_per_op\":696851,\"bytes_per_op_max\":235463295,\"allocs_per_op_max\":696851},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":50194,\"measured_allocs_per_op\":336,\"bytes_per_op_max\":50194,\"allocs_per_op_max\":336},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":213832,\"measured_allocs_per_op\":282,\"bytes_per_op_max\":213832,\"allocs_per_op_max\":282},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":43419,\"measured_allocs_per_op\":263,\"bytes_per_op_max\":43419,\"allocs_per_op_max\":263},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":885698,\"measured_allocs_per_op\":262,\"bytes_per_op_max\":885698,\"allocs_per_op_max\":262},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":261837,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261837,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1072366,\"measured_allocs_per_op\":3840,\"bytes_per_op_max\":1072366,\"allocs_per_op_max\":3840},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9506120,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9506120,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":260018,\"measured_allocs_per_op\":556,\"bytes_per_op_max\":260018,\"allocs_per_op_max\":556},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9503936,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9503936,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":14273,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14273,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":14778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":432603,\"measured_allocs_per_op\":2948,\"bytes_per_op_max\":432603,\"allocs_per_op_max\":2948},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10061,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10061,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":10407,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":10407,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":440815,\"measured_allocs_per_op\":2946,\"bytes_per_op_max\":440815,\"allocs_per_op_max\":2946},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":440657,\"measured_allocs_per_op\":2945,\"bytes_per_op_max\":440657,\"allocs_per_op_max\":2945},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163584,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163584,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}]}},\"entries\":[{\"id\":\"hook.bash_short\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"bash_short\",\"payload_bytes\":400,\"benchmark\":\"BenchmarkRunHook/bash_short\",\"alloc_bytes_max\":54058,\"maxrss_mib_p50\":5.95,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":10,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths\"},{\"id\":\"hook.edit_50k\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"edit_50k\",\"payload_bytes\":51200,\"benchmark\":\"BenchmarkRunHook/edit_50k\",\"alloc_bytes_max\":218378,\"maxrss_mib_p50\":6.20,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":25,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB\"},{\"id\":\"post.bash_short\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"bash_short\",\"payload_bytes\":540,\"benchmark\":\"BenchmarkRunPost/bash_short\",\"alloc_bytes_max\":46893,\"maxrss_mib_p50\":5.94,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":11,\"wall_ms_p95\":18,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.94 / 5.89 / 6.00 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"post.edit_314k\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"edit_314k\",\"payload_bytes\":314000,\"benchmark\":\"BenchmarkRunPost/edit_314k\",\"alloc_bytes_max\":892802,\"maxrss_mib_p50\":6.73,\"maxrss_mib_p95\":7.16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":12,\"wall_ms_p95\":22,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.73 / 6.67 / 6.73 MiB); p95 is RES-28's line at 314 KB (7.16 MiB), which the archive applies from P-10 in place of 8 / 10 MiB; 60 runs gave p95 6.92, max 6.95\"},{\"id\":\"recap.stop\",\"events\":[\"Stop\",\"StopFailure\"],\"command\":[\"recap\"],\"payload_class\":\"stop_100\",\"payload_bytes\":2048,\"benchmark\":\"BenchmarkRecap/100\",\"alloc_bytes_max\":1075559,\"maxrss_mib_p50\":7.06,\"maxrss_mib_p95\":16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":20,\"wall_ms_p95\":60,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (7.06 / 7.03 / 7.06 MiB); on the 100-call session BenchmarkRecap/100 reads; expected per archive 85.4.1: 12 / 16 MiB, flat in session size\"},{\"id\":\"recap.pending\",\"events\":[\"UserPromptSubmit\"],\"command\":[\"recap\",\"pending\"],\"payload_class\":\"prompt\",\"payload_bytes\":256,\"benchmark\":\"BenchmarkRecapPending/10\",\"alloc_bytes_max\":261190,\"maxrss_mib_p50\":6.06,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":8,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.09 / 6.06 / 5.98 MiB); on the 10-call session BenchmarkRecapPending/10 reads (the earlier 5.2 measured the no-store early return); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"probe.start\",\"events\":[\"SessionStart\"],\"command\":[\"probe\",\"start\"],\"payload_class\":\"session_start\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.97,\"maxrss_mib_p95\":24,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":400,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.02 / 5.97 / 5.91 MiB); nothing queued; expected per archive 85.4.1: 8 MiB with nothing queued at p50, mining and briefing at p95\"},{\"id\":\"probe.end\",\"events\":[\"SessionEnd\"],\"command\":[\"probe\",\"end\"],\"payload_class\":\"session_end\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.98,\"maxrss_mib_p95\":6.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":6,\"wall_ms_p95\":12,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.95 / 6.05 / 5.98 MiB); closing a started session; expected per archive 85.4.1: one record, 6 / 6.5 MiB\"}],\"rss_cases\":[{\"id\":\"hook.400B\",\"entry\":\"hook\",\"payload_bytes\":400,\"budget_mib\":6.5008,\"measured_mib\":5.97,\"gates_from\":\"P-08\"},{\"id\":\"hook.40KB\",\"entry\":\"hook\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.12,\"gates_from\":\"P-08\"},{\"id\":\"hook.1MiB\",\"entry\":\"hook\",\"payload_bytes\":1048576,\"budget_mib\":9.9,\"measured_mib\":9.48,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.4MiB\",\"entry\":\"hook\",\"payload_bytes\":4194304,\"budget_mib\":20.1,\"measured_mib\":19.72,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.8MiB\",\"entry\":\"hook\",\"payload_bytes\":8388608,\"budget_mib\":33.7,\"measured_mib\":31.88,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.540B\",\"entry\":\"post\",\"payload_bytes\":540,\"budget_mib\":6.5011,\"measured_mib\":6.06,\"gates_from\":\"P-08\"},{\"id\":\"post.40KB\",\"entry\":\"post\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.05,\"gates_from\":\"P-08\"},{\"id\":\"post.1MiB\",\"entry\":\"post\",\"payload_bytes\":1048576,\"budget_mib\":8.95,\"measured_mib\":8.86,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.4MiB\",\"entry\":\"post\",\"payload_bytes\":4194304,\"budget_mib\":16.3,\"measured_mib\":15.42,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.8MiB\",\"entry\":\"post\",\"payload_bytes\":8388608,\"budget_mib\":26.1,\"measured_mib\":23.66,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"}],\"per_tool_call\":{\"memory_time_mib_ms_max\":379,\"summed_peak_mib_max\":32,\"process_creations_max\":{\"settings\":6,\"plugin\":6}},\"parallel\":{\"ways\":64,\"summed_peak_mib_max\":704,\"basis\":\"per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured\"},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":3280,\"measured_allocs_per_op\":59,\"bytes_per_op_max\":3280,\"allocs_per_op_max\":59},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":421153,\"measured_allocs_per_op\":2301,\"bytes_per_op_max\":421153,\"allocs_per_op_max\":2301},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":17838,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":17838,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1886832,\"measured_allocs_per_op\":6828,\"bytes_per_op_max\":1886832,\"allocs_per_op_max\":6828},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8422345,\"measured_allocs_per_op\":31708,\"bytes_per_op_max\":8422345,\"allocs_per_op_max\":31708},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36024352,\"measured_allocs_per_op\":121991,\"bytes_per_op_max\":36024352,\"allocs_per_op_max\":121991},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235467679,\"measured_allocs_per_op\":696819,\"bytes_per_op_max\":235467679,\"allocs_per_op_max\":696819},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":54058,\"measured_allocs_per_op\":362,\"bytes_per_op_max\":54058,\"allocs_per_op_max\":362},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":218378,\"measured_allocs_per_op\":310,\"bytes_per_op_max\":218378,\"allocs_per_op_max\":310},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":46893,\"measured_allocs_per_op\":290,\"bytes_per_op_max\":46893,\"allocs_per_op_max\":290},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":892802,\"measured_allocs_per_op\":295,\"bytes_per_op_max\":892802,\"allocs_per_op_max\":295},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":263952,\"measured_allocs_per_op\":558,\"bytes_per_op_max\":263952,\"allocs_per_op_max\":558},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1075559,\"measured_allocs_per_op\":3849,\"bytes_per_op_max\":1075559,\"allocs_per_op_max\":3849},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9513616,\"measured_allocs_per_op\":35359,\"bytes_per_op_max\":9513616,\"allocs_per_op_max\":35359},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":261190,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261190,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9511308,\"measured_allocs_per_op\":35356,\"bytes_per_op_max\":9511308,\"allocs_per_op_max\":35356},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":15778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":15778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":16426,\"measured_allocs_per_op\":83,\"bytes_per_op_max\":16426,\"allocs_per_op_max\":83},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":483310,\"measured_allocs_per_op\":3142,\"bytes_per_op_max\":483310,\"allocs_per_op_max\":3142},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10646,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10646,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":11351,\"measured_allocs_per_op\":51,\"bytes_per_op_max\":11351,\"allocs_per_op_max\":51},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":478744,\"measured_allocs_per_op\":2949,\"bytes_per_op_max\":478744,\"allocs_per_op_max\":2949},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":499297,\"measured_allocs_per_op\":3138,\"bytes_per_op_max\":499297,\"allocs_per_op_max\":3138},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163589,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163589,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459141,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459141,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}],\"growth\":[],\"disk\":{\"bytes_per_call_max\":6000}}" +const tableJSON = "{\"budgets_version\":1,\"go_version\":\"go1.27.3\",\"benchmark_rows\":\"the measured best of three on the reference machine (darwin/arm64, go1.27.3, -benchtime 20x, 2026-10-22); a row's max equals its measurement, and the gate's ten percent (scripts/benchgate) is the only margin\",\"benchmark_platform\":\"darwin/arm64\",\"platforms\":{\"darwin/arm64\":{\"reference\":\"Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \\\"-s -w\\\")\",\"set_from\":\"measured 2026-10-13 on the P-06 tree (archive 85.1.2)\",\"tolerance\":0.15,\"tolerance_until\":\"2026-11-06\",\"tolerance_after\":0.1,\"entries\":{},\"case_budgets\":{}},\"linux/amd64\":{\"reference\":\"ubuntu-latest\",\"set_from\":\"measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on d2071b6 by the measure-linux-benchmarks job, run 39762603829 (best of three, -benchtime 20x)\",\"tolerance\":0.1,\"tolerance_until\":\"\",\"tolerance_after\":0.1,\"entries\":{\"hook.bash_short\":{\"p50\":7.71,\"p95\":7.71},\"post.bash_short\":{\"p50\":7.65,\"p95\":7.65},\"hook.edit_50k\":{\"p50\":9.67,\"p95\":9.67},\"post.edit_314k\":{\"p50\":7.83,\"p95\":7.83},\"recap.stop\":{\"p50\":8.07,\"p95\":8.07},\"recap.pending\":{\"p50\":7.93,\"p95\":7.93},\"probe.start\":{\"p50\":7.64,\"p95\":7.64},\"probe.end\":{\"p50\":7.66,\"p95\":7.66}},\"case_budgets\":{\"hook.400B\":7.64,\"hook.40KB\":7.7,\"post.540B\":7.65,\"post.40KB\":7.65},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":4624,\"measured_allocs_per_op\":71,\"bytes_per_op_max\":4624,\"allocs_per_op_max\":71},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":430689,\"measured_allocs_per_op\":2333,\"bytes_per_op_max\":430689,\"allocs_per_op_max\":2333},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":16550,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":16550,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1885613,\"measured_allocs_per_op\":6829,\"bytes_per_op_max\":1885613,\"allocs_per_op_max\":6829},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8417557,\"measured_allocs_per_op\":31710,\"bytes_per_op_max\":8417557,\"allocs_per_op_max\":31710},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36038866,\"measured_allocs_per_op\":122027,\"bytes_per_op_max\":36038866,\"allocs_per_op_max\":122027},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235463295,\"measured_allocs_per_op\":696851,\"bytes_per_op_max\":235463295,\"allocs_per_op_max\":696851},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":50194,\"measured_allocs_per_op\":336,\"bytes_per_op_max\":50194,\"allocs_per_op_max\":336},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":213832,\"measured_allocs_per_op\":282,\"bytes_per_op_max\":213832,\"allocs_per_op_max\":282},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":43419,\"measured_allocs_per_op\":263,\"bytes_per_op_max\":43419,\"allocs_per_op_max\":263},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":885698,\"measured_allocs_per_op\":262,\"bytes_per_op_max\":885698,\"allocs_per_op_max\":262},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":261837,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261837,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1072366,\"measured_allocs_per_op\":3840,\"bytes_per_op_max\":1072366,\"allocs_per_op_max\":3840},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9506120,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9506120,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":260018,\"measured_allocs_per_op\":556,\"bytes_per_op_max\":260018,\"allocs_per_op_max\":556},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9503936,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9503936,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":14273,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14273,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":14778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":432603,\"measured_allocs_per_op\":2948,\"bytes_per_op_max\":432603,\"allocs_per_op_max\":2948},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10061,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10061,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":10407,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":10407,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":440815,\"measured_allocs_per_op\":2946,\"bytes_per_op_max\":440815,\"allocs_per_op_max\":2946},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":440657,\"measured_allocs_per_op\":2945,\"bytes_per_op_max\":440657,\"allocs_per_op_max\":2945},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163584,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163584,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}]}},\"entries\":[{\"id\":\"hook.bash_short\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"bash_short\",\"payload_bytes\":400,\"benchmark\":\"BenchmarkRunHook/bash_short\",\"alloc_bytes_max\":54058,\"maxrss_mib_p50\":5.95,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":10,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths\"},{\"id\":\"hook.edit_50k\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"edit_50k\",\"payload_bytes\":51200,\"benchmark\":\"BenchmarkRunHook/edit_50k\",\"alloc_bytes_max\":218378,\"maxrss_mib_p50\":6.20,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":25,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB\"},{\"id\":\"post.bash_short\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"bash_short\",\"payload_bytes\":540,\"benchmark\":\"BenchmarkRunPost/bash_short\",\"alloc_bytes_max\":46893,\"maxrss_mib_p50\":5.94,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":11,\"wall_ms_p95\":18,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.94 / 5.89 / 6.00 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"post.edit_314k\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"edit_314k\",\"payload_bytes\":314000,\"benchmark\":\"BenchmarkRunPost/edit_314k\",\"alloc_bytes_max\":892802,\"maxrss_mib_p50\":6.73,\"maxrss_mib_p95\":7.16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":12,\"wall_ms_p95\":22,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.73 / 6.67 / 6.73 MiB); p95 is RES-28's line at 314 KB (7.16 MiB), which the archive applies from P-10 in place of 8 / 10 MiB; 60 runs gave p95 6.92, max 6.95\"},{\"id\":\"recap.stop\",\"events\":[\"Stop\",\"StopFailure\"],\"command\":[\"recap\"],\"payload_class\":\"stop_100\",\"payload_bytes\":2048,\"benchmark\":\"BenchmarkRecap/100\",\"alloc_bytes_max\":1075559,\"maxrss_mib_p50\":7.06,\"maxrss_mib_p95\":16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":20,\"wall_ms_p95\":60,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (7.06 / 7.03 / 7.06 MiB); on the 100-call session BenchmarkRecap/100 reads; expected per archive 85.4.1: 12 / 16 MiB, flat in session size\"},{\"id\":\"recap.pending\",\"events\":[\"UserPromptSubmit\"],\"command\":[\"recap\",\"pending\"],\"payload_class\":\"prompt\",\"payload_bytes\":256,\"benchmark\":\"BenchmarkRecapPending/10\",\"alloc_bytes_max\":261190,\"maxrss_mib_p50\":6.06,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":8,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.09 / 6.06 / 5.98 MiB); on the 10-call session BenchmarkRecapPending/10 reads (the earlier 5.2 measured the no-store early return); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"probe.start\",\"events\":[\"SessionStart\"],\"command\":[\"probe\",\"start\"],\"payload_class\":\"session_start\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.97,\"maxrss_mib_p95\":24,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":400,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.02 / 5.97 / 5.91 MiB); nothing queued; expected per archive 85.4.1: 8 MiB with nothing queued at p50, mining and briefing at p95\"},{\"id\":\"probe.end\",\"events\":[\"SessionEnd\"],\"command\":[\"probe\",\"end\"],\"payload_class\":\"session_end\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.98,\"maxrss_mib_p95\":6.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":6,\"wall_ms_p95\":12,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.95 / 6.05 / 5.98 MiB); closing a started session; expected per archive 85.4.1: one record, 6 / 6.5 MiB\"}],\"rss_cases\":[{\"id\":\"hook.400B\",\"entry\":\"hook\",\"payload_bytes\":400,\"budget_mib\":6.5008,\"measured_mib\":5.97,\"gates_from\":\"P-08\"},{\"id\":\"hook.40KB\",\"entry\":\"hook\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.12,\"gates_from\":\"P-08\"},{\"id\":\"hook.1MiB\",\"entry\":\"hook\",\"payload_bytes\":1048576,\"budget_mib\":9.9,\"measured_mib\":9.48,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.4MiB\",\"entry\":\"hook\",\"payload_bytes\":4194304,\"budget_mib\":20.1,\"measured_mib\":19.72,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.8MiB\",\"entry\":\"hook\",\"payload_bytes\":8388608,\"budget_mib\":33.7,\"measured_mib\":31.88,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.540B\",\"entry\":\"post\",\"payload_bytes\":540,\"budget_mib\":6.5011,\"measured_mib\":6.06,\"gates_from\":\"P-08\"},{\"id\":\"post.40KB\",\"entry\":\"post\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.05,\"gates_from\":\"P-08\"},{\"id\":\"post.1MiB\",\"entry\":\"post\",\"payload_bytes\":1048576,\"budget_mib\":8.95,\"measured_mib\":8.86,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.4MiB\",\"entry\":\"post\",\"payload_bytes\":4194304,\"budget_mib\":16.3,\"measured_mib\":15.42,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.8MiB\",\"entry\":\"post\",\"payload_bytes\":8388608,\"budget_mib\":26.1,\"measured_mib\":23.66,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"}],\"per_tool_call\":{\"memory_time_mib_ms_max\":379,\"summed_peak_mib_max\":32,\"process_creations_max\":{\"settings\":6,\"plugin\":6}},\"parallel\":{\"ways\":64,\"summed_peak_mib_max\":704,\"basis\":\"per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured\"},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":2656,\"measured_allocs_per_op\":33,\"bytes_per_op_max\":2656,\"allocs_per_op_max\":33},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":285328,\"measured_allocs_per_op\":229,\"bytes_per_op_max\":285328,\"allocs_per_op_max\":229},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":17838,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":17838,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1886832,\"measured_allocs_per_op\":6828,\"bytes_per_op_max\":1886832,\"allocs_per_op_max\":6828},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8422345,\"measured_allocs_per_op\":31708,\"bytes_per_op_max\":8422345,\"allocs_per_op_max\":31708},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36024352,\"measured_allocs_per_op\":121991,\"bytes_per_op_max\":36024352,\"allocs_per_op_max\":121991},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235467679,\"measured_allocs_per_op\":696819,\"bytes_per_op_max\":235467679,\"allocs_per_op_max\":696819},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":54058,\"measured_allocs_per_op\":362,\"bytes_per_op_max\":54058,\"allocs_per_op_max\":362},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":218378,\"measured_allocs_per_op\":310,\"bytes_per_op_max\":218378,\"allocs_per_op_max\":310},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":46893,\"measured_allocs_per_op\":290,\"bytes_per_op_max\":46893,\"allocs_per_op_max\":290},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":892802,\"measured_allocs_per_op\":295,\"bytes_per_op_max\":892802,\"allocs_per_op_max\":295},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":263952,\"measured_allocs_per_op\":558,\"bytes_per_op_max\":263952,\"allocs_per_op_max\":558},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1075559,\"measured_allocs_per_op\":3849,\"bytes_per_op_max\":1075559,\"allocs_per_op_max\":3849},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9513616,\"measured_allocs_per_op\":35359,\"bytes_per_op_max\":9513616,\"allocs_per_op_max\":35359},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":261190,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261190,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9511308,\"measured_allocs_per_op\":35356,\"bytes_per_op_max\":9511308,\"allocs_per_op_max\":35356},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":15778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":15778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":16426,\"measured_allocs_per_op\":83,\"bytes_per_op_max\":16426,\"allocs_per_op_max\":83},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":483310,\"measured_allocs_per_op\":3142,\"bytes_per_op_max\":483310,\"allocs_per_op_max\":3142},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10646,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10646,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":11351,\"measured_allocs_per_op\":51,\"bytes_per_op_max\":11351,\"allocs_per_op_max\":51},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":478744,\"measured_allocs_per_op\":2949,\"bytes_per_op_max\":478744,\"allocs_per_op_max\":2949},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":499297,\"measured_allocs_per_op\":3138,\"bytes_per_op_max\":499297,\"allocs_per_op_max\":3138},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163589,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163589,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459141,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459141,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}],\"growth\":[],\"disk\":{\"bytes_per_call_max\":6000}}" diff --git a/internal/hook/res10_test.go b/internal/hook/res10_test.go index 3a42ce3b..9f866c51 100644 --- a/internal/hook/res10_test.go +++ b/internal/hook/res10_test.go @@ -74,16 +74,18 @@ func TestRES10_LeadingCdFoldIsBounded(t *testing.T) { } } - // Past the token bound: a long line after the leading steps. - long := " " + strings.TrimSpace(strings.Repeat("word ", 50_000)) + // Past the token bound: a long line after the leading steps, of 200,000 + // words, three times the bound (shape's TestRES10_LeadingStepsPastTheTokenBound + // pins the line it reads past). + long := " " + strings.TrimSpace(strings.Repeat("word ", 200_000)) for _, tc := range []struct { name, cmd, want string }{ - {"one cd, then 50,000 words", "cd /elsewhere && go test" + long, "/elsewhere"}, - {"one cd, then a 50,000-word here-document", + {"one cd, then 200,000 words", "cd /elsewhere && go test" + long, "/elsewhere"}, + {"one cd, then a 200,000-word here-document", "cd sub && python3 - <<'EOF'\n" + long + "\nEOF\nmake test", "/repo/sub"}, - {"64 steps, then 50,000 words", cdLine(64, " && ") + long, "/repo" + strings.Repeat("/a", 64)}, - {"100 steps, then 50,000 words", cdLine(100, " && ") + long, ""}, + {"64 steps, then 200,000 words", cdLine(64, " && ") + long, "/repo" + strings.Repeat("/a", 64)}, + {"100 steps, then 200,000 words", cdLine(100, " && ") + long, ""}, } { if got := startDirectory("/repo", "Bash", in(tc.cmd)); got != tc.want { t.Errorf("%s: start directory %q, want %q", tc.name, got, tc.want) diff --git a/internal/shape/res10_test.go b/internal/shape/res10_test.go index 5fa5c67a..b0630a07 100644 --- a/internal/shape/res10_test.go +++ b/internal/shape/res10_test.go @@ -42,10 +42,11 @@ func TestRES10_ProgramPastManyDirectoryChanges(t *testing.T) { // token bound (RES-9) still has its leading steps read, since they come first // and the longest run read, MaxLeadingCd+1 steps of three tokens, lies far // inside the bound. The masked-status walk counts the same steps the hook -// folds into the cwd digest: one cd in front of a 50,000-word here-document -// is one step held, and 100 in front of 50,000 words are past the fold. +// folds into the cwd digest: one cd in front of a here-document longer than +// the bound is one step held, and 100 in front of as many words are past the +// fold. func TestRES10_LeadingStepsPastTheTokenBound(t *testing.T) { - long := strings.TrimSpace(strings.Repeat("word ", 50_000)) + long := strings.TrimSpace(strings.Repeat("word ", maxTokens+10_000)) heredoc := "cd sub && python3 - <<'EOF'\n" + long + "\nEOF\nmake test" if _, err := tokenizeProgram(heredoc); err != errTokenBound { t.Fatalf("the here-document line reads with %v, want errTokenBound", err) @@ -55,7 +56,7 @@ func TestRES10_LeadingStepsPastTheTokenBound(t *testing.T) { } dirs, ok := leadingDirectories(strings.Repeat("cd a && ", 100) + long) if !ok || len(dirs) != MaxLeadingCd+1 { - t.Errorf("100 cds before 50,000 words: %d directories, %v; want %d, true", len(dirs), ok, MaxLeadingCd+1) + t.Errorf("100 cds before the bound's worth of words: %d directories, %v; want %d, true", len(dirs), ok, MaxLeadingCd+1) } } diff --git a/internal/shape/tokenize.go b/internal/shape/tokenize.go index 5e9eaba1..a446184d 100644 --- a/internal/shape/tokenize.go +++ b/internal/shape/tokenize.go @@ -30,20 +30,22 @@ var errTokenBound = errors.New("token bound") // drove a hook to about 1.07 GB of RSS. Lexing stops at maxTokens, derived // from a memory budget for the shape of one call, shapeBudget, over the cost // of one token. That cost is the whole Derive's, not the lexer's: Derive reads -// a line three times (tokenizeShape for argc, tokenizeProgram for the -// program, tokenizeList for the masked status), its leading-cd count a fourth -// and the hook's LeadingDirectory a fifth, so a token costs about 395 bytes -// where one reading of it costs about 106 (BenchmarkLexTokens). -// bytesPerToken is BenchmarkDerive/bash_long_4k's 421,153 B/op over that -// line's 1,068 tokens (test/perf/budgets.json, measured 2026-10-13 under -// go1.27.2), 394.3, rounded up; TestRES9_BoundMatchesItsArithmetic holds the -// constant to that row, so a change that moves Derive's cost per token -// restates the bound with the row. The bound is 42,473 tokens: a script -// handed to an interpreter as a here-document, which the program search -// reads word by word, runs to a few thousand. +// a line three times (tokenizeShape for argc, tokenizeProgram for the program +// and the leading-cd count, tokenizeList for the masked status), so a token +// costs about 268 bytes where one reading of it costs about 101 +// (BenchmarkLexTokens). The hook's LeadingDirectory reads the line once more, +// outside Derive. bytesPerToken is BenchmarkDerive/bash_long_4k's 285,328 +// B/op over that line's 1,068 tokens (test/perf/budgets.json, measured +// 2026-10-13 under go1.27.2), 267.2, rounded up; it was 395 while a word's +// text was copied byte by byte and the leading-cd count read the line a +// fourth time. TestRES9_BoundMatchesItsArithmetic holds the constant to that +// row, so a change that moves Derive's cost per token restates the bound +// with the row. The bound is 62,601 tokens: a script handed to an +// interpreter as a here-document, which the program search reads word by +// word, runs to a few thousand. const ( shapeBudget = 16 << 20 - bytesPerToken = 395 + bytesPerToken = 268 maxTokens = shapeBudget / bytesPerToken ) @@ -573,11 +575,17 @@ func (l *lexer) plain(i int) { // the token slice is allocated once rather than grown. Grown, append's copies // of a slice this size cost about four times the slice it leaves, and at the // token bound that was most of what one reading of a line allocated. Where it -// is short, as on a line of quoted blanks, lex grows the slice as before. +// is short, as on a line of quoted blanks, lex grows the slice as before. An +// escaped blank ends no word: 8 MiB of `\ ` is one word, and counted as +// millions the line was given a slice for the bound's worth of tokens in +// each of its readings. func tokenEstimate(s string) int { n, inWord := 1, false for i := 0; i < len(s) && n <= maxTokens; i++ { switch c := s[i]; { + case c == '\\': // the byte it escapes is the word's, a blank included + i++ + inWord = true case c == ' ' || c == '\t' || c == '\n' || c == '\r': if inWord { n++ diff --git a/test/mutation/mutants/RES-29_shape-memory.py b/test/mutation/mutants/RES-29_shape-memory.py index d69b7fa9..bbb9dc1d 100644 --- a/test/mutation/mutants/RES-29_shape-memory.py +++ b/test/mutation/mutants/RES-29_shape-memory.py @@ -30,3 +30,6 @@ "\tssh = collect(lower, sshSchemes)\n", "\tssh = collect(strings.ToLower(text), sshSchemes)\n", "TestRES29_") m("RES-29 the command is decoded into a buffer grown from nothing", "internal/jsonscan/unquote.go", "\tvar out strings.Builder\n\tout.Grow(len(b))\n", "\tvar out strings.Builder\n", "TestRES29_") +m("RES-29 an escaped blank ends a word in the token estimate", "internal/shape/tokenize.go", + "\t\tcase c == '\\\\': // the byte it escapes is the word's, a blank included\n\t\t\ti++\n\t\t\tinWord = true\n", + "", "TestRES29_") diff --git a/test/mutation/mutants/RES-36_budgets-file.py b/test/mutation/mutants/RES-36_budgets-file.py index 68b0e61f..ff78e947 100644 --- a/test/mutation/mutants/RES-36_budgets-file.py +++ b/test/mutation/mutants/RES-36_budgets-file.py @@ -15,5 +15,5 @@ # The reference row, on its own indented line: linux/amd64's inline row for the # same benchmark can carry the very same figures. m("RES-36 a benchmark row hides a margin above its measurement", "test/perf/budgets.json", - ' {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 3280, "measured_allocs_per_op": 59, "bytes_per_op_max": 3280, "allocs_per_op_max": 59}', - ' {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 3280, "measured_allocs_per_op": 59, "bytes_per_op_max": 3608, "allocs_per_op_max": 64}', "TestRES36d_") + ' {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 2656, "measured_allocs_per_op": 33, "bytes_per_op_max": 2656, "allocs_per_op_max": 33}', + ' {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 2656, "measured_allocs_per_op": 33, "bytes_per_op_max": 2921, "allocs_per_op_max": 36}', "TestRES36d_") diff --git a/test/perf/budgets.json b/test/perf/budgets.json index e6f23e78..6370eb9f 100644 --- a/test/perf/budgets.json +++ b/test/perf/budgets.json @@ -29,8 +29,8 @@ "per_tool_call": {"memory_time_mib_ms_max": 379, "summed_peak_mib_max": 32, "process_creations_max": {"settings": 6, "plugin": 6}}, "parallel": {"ways": 64, "summed_peak_mib_max": 704, "basis": "per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured"}, "benchmarks": [ - {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 3280, "measured_allocs_per_op": 59, "bytes_per_op_max": 3280, "allocs_per_op_max": 59}, - {"name": "BenchmarkDerive/bash_long_4k", "measured_bytes_per_op": 421153, "measured_allocs_per_op": 2301, "bytes_per_op_max": 421153, "allocs_per_op_max": 2301}, + {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 2656, "measured_allocs_per_op": 33, "bytes_per_op_max": 2656, "allocs_per_op_max": 33}, + {"name": "BenchmarkDerive/bash_long_4k", "measured_bytes_per_op": 285328, "measured_allocs_per_op": 229, "bytes_per_op_max": 285328, "allocs_per_op_max": 229}, {"name": "BenchmarkDerive/edit_50k", "measured_bytes_per_op": 63464, "measured_allocs_per_op": 28, "bytes_per_op_max": 63464, "allocs_per_op_max": 28}, {"name": "BenchmarkLexTokens", "measured_bytes_per_op": 128544, "measured_allocs_per_op": 750, "bytes_per_op_max": 128544, "allocs_per_op_max": 750}, {"name": "BenchmarkRecordBytesPerCall", "measured_bytes_per_op": 17838, "measured_allocs_per_op": 95, "bytes_per_op_max": 17838, "allocs_per_op_max": 95}, From 0416c0a925f14549972b81be76e901b8b021884c Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 13:22:37 -0700 Subject: [PATCH 13/50] hook: say what bounds the work on a payload, and that it is measured MaxPayloadBytes' comment said "the work is bounded where it is done", naming the token bound and the leading-cd fold. On the tree it was written for that overstated it: a line of backslash-newlines, one word of $a, or 8 MiB of `ssh a` lines each drove a hook past RES-29's 64 MiB, to 87, 84 and 178 MiB. It now names each reader's own bound -- the token bound, the continuation offsets and ssh lines held to it, plain words sliced rather than copied, one decoding per reader -- and says what kind of claim it is: measured on the sixteen line shapes the RES-29 allocation test reads, where no reader allocates more than five times the payload and the hook peaked under 58 MiB, not proved for every line. --- internal/hook/handle.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/hook/handle.go b/internal/hook/handle.go index f9a59f83..1243f073 100644 --- a/internal/hook/handle.go +++ b/internal/hook/handle.go @@ -24,9 +24,15 @@ import ( // The limit bounds the bytes read, not the work done on them, and alone it // does not close that path: 8 MiB of one-letter words is four million shell // words, and lexed whole the shape of that one line allocated 4.5 GB and -// drove a hook to about 1.07 GB of RSS. The work is bounded where it is done: -// the shape package stops lexing at its token bound (RES-9), and the -// leading-cd fold below stops at maxLeadingCd (RES-10). +// drove a hook to about 1.07 GB of RSS. Each reader of the line bounds its +// own work: the shape package stops lexing at its token bound (RES-9), keeps +// no more continuation offsets than that and reads ssh lines to the same +// bound, takes a plain word as a slice of the line, and decodes the command +// once per reader into a buffer of its size (RES-29); the leading-cd fold +// below stops at maxLeadingCd (RES-10). That is measured, not proved: on the +// sixteen 8 MiB line shapes TestRES29_ShapeOfEveryPathologicalLineIsBounded +// reads, no reader allocates more than five times the payload, and the hook +// peaked under 58 MiB of RSS on each shape tried. const MaxPayloadBytes = hookio.MaxPayloadBytes // maxLeadingCd bounds the leading-cd fold in startDirectory: see From 43aac9e4c0647982c0d719c83b85f5463db9a128 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 13:29:48 -0700 Subject: [PATCH 14/50] shape: split shape.go by what each part reads shape.go was 1,041 code lines against the 500-line limit (Law 10), and the earlier commits on this branch had grown it. It is now six files, each holding one reading of a line, moved without a change to any line: shape.go Derive, the Shape record, digests and the command field (138) program.go the program search: programToken, pastDirectoryChange, plainWord, commandEnd and the group tracking (235) leading.go LeadingDirectory, MaxLeadingCd and the leading-cd run (70) join.go joinContinuations, maxJoins and joinLines (65) words.go word predicates: comments, redirections, assignments (149) verb.go verb classes, test runners, may_write and output targets (408) The one other edit is the package comment, which said that one file had to be audited for the no-content guarantee; it has been a package of several files for some time, and now says so. 149 mutants anchored in shape.go name the file their anchor moved to, re-pointed by matching each anchor to the one file that holds it; the sweep's --check finds every anchor exactly once. --- internal/shape/join.go | 91 ++ internal/shape/leading.go | 129 ++ internal/shape/program.go | 404 +++++ internal/shape/shape.go | 1518 +----------------- internal/shape/verb.go | 689 ++++++++ internal/shape/words.go | 226 +++ test/mutation/mutants/FF_shape.py | 4 +- test/mutation/mutants/H-13_shape.py | 128 +- test/mutation/mutants/LK_account.py | 6 +- test/mutation/mutants/MS_masked.py | 8 +- test/mutation/mutants/RES-10_leading-cd.py | 6 +- test/mutation/mutants/RES-29_shape-memory.py | 4 +- test/mutation/mutants/RES-5_fold-keys.py | 2 +- test/mutation/mutants/TB_shape.py | 134 +- 14 files changed, 1687 insertions(+), 1662 deletions(-) create mode 100644 internal/shape/join.go create mode 100644 internal/shape/leading.go create mode 100644 internal/shape/program.go create mode 100644 internal/shape/verb.go create mode 100644 internal/shape/words.go diff --git a/internal/shape/join.go b/internal/shape/join.go new file mode 100644 index 00000000..f76df4b3 --- /dev/null +++ b/internal/shape/join.go @@ -0,0 +1,91 @@ +package shape + +import "strings" + +// joinContinuations removes every backslash-newline the shell removes before +// it splits a line into words: outside quotes and inside double quotes, but +// not inside single quotes, where a backslash is literal. It returns the +// line with them removed and the offsets in it at which it removed one, +// ascending: the byte there is the one that followed it. Its quote tracking +// is flat, which is right outside a $( ) and may be wrong inside one; see +// comsubEnd. +// +// It keeps the first maxJoins offsets and no more (joined.lost): one per +// continuation, 8 MiB of `a\` kept 2.8 million of them, 22 MiB, +// in each reading of the line. The line itself is joined whole. +func joinContinuations(s string) joined { + return joinLines(s, false) +} + +// maxJoins is how many offsets joinContinuations keeps: as many as the +// token bound lets a line have tokens (RES-9), 340 KB of them. Past them the +// readers that ask where a backslash-newline was removed, a here-document's +// body and a $( ) inside double quotes, give up rather than guess. +const maxJoins = maxTokens + +// joinLines is joinContinuations; with comments, an unquoted # at the start +// of a word runs to the end of its line, as a comment does to the shell, and +// a backslash at its end continues nothing: `# run the tests \` and then +// `make test | tail` on the next line runs make. +func joinLines(s string, comments bool) joined { + n := strings.Count(s, "\\\n") + if n == 0 { + return joined{s: s} + } + var ( + b strings.Builder + joins = make([]int, 0, min(n, maxJoins)) + lost bool + ) + // The line loses bytes, except where a comment or a single quote keeps + // a backslash-newline, so it is grown once to the line's length. + b.Grow(len(s)) + inSingle, inDouble := false, false + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case comments && c == '#' && !inSingle && !inDouble && wordStart(b.String()): + for ; i < len(s) && s[i] != '\n'; i++ { + b.WriteByte(s[i]) + } + i-- + continue + case inSingle: + if c == '\'' { + inSingle = false + } + case c == '\\' && i+1 < len(s): + if s[i+1] == '\n' { + if len(joins) < maxJoins { + joins = append(joins, b.Len()) + } else { + lost = true + } + i++ + continue + } + b.WriteByte(c) + i++ + c = s[i] + case c == '\'' && !inDouble: + inSingle = true + case c == '"': + inDouble = !inDouble + } + b.WriteByte(c) + } + return joined{s: b.String(), joins: joins, lost: lost} +} + +// wordStart reports whether a word would begin after the text written so +// far: at its start, or after a blank or an operator. +func wordStart(written string) bool { + if written == "" { + return true + } + switch written[len(written)-1] { + case ' ', '\t', '\n', ';', '&', '|', '(', ')', '<', '>': + return true + } + return false +} diff --git a/internal/shape/leading.go b/internal/shape/leading.go new file mode 100644 index 00000000..4e15930c --- /dev/null +++ b/internal/shape/leading.go @@ -0,0 +1,129 @@ +package shape + +import ( + "encoding/json" + "strings" +) + +// LeadingDirectory reports the directories a shell call's line moves through +// before anything else runs, when the line begins with plain literal +// `cd DIR &&` steps: each DIR as written, in order, which the caller folds +// onto the call's cwd. The command after each `&&` runs only if its cd +// succeeded, so the runner starts where the last one leads. +// +// Only a word the shell takes as it stands: nothing it expands or unquotes +// (`$`, backticks, a glob, `~`, quotes or an escape), not `-`, which is the +// previous directory, and not a comment. `cd DIR;` is left out, since what +// follows a `;` runs whether the cd succeeded or not. A step in the leading +// run whose command, past any assignments, redirections or an opening ( or { +// in front of it, is cd, pushd or popd and is not such a cd spoils the run, +// since the directory it leads to is not on the line. So a `cd -` is refused +// anywhere in the leading run, even when an earlier step names its target: +// `cd /a && cd b && cd - && go test` loses a pair that way, but it never +// forms a false one. Anything else reports false, and the call keeps the +// payload's cwd. +func LeadingDirectory(toolName string, toolInput json.RawMessage) ([]string, bool) { + if verbForTool(toolName) != VerbExecute { + return nil, false + } + cmd, ok := commandField(toolInput) + if !ok || controlByte(cmd) { + return nil, false + } + return leadingDirectories(cmd) +} + +// MaxLeadingCd is how many leading `cd DIR &&` steps a call's start +// directory is folded from (RES-10). The hook folds no more and records the +// directory as unknown past them: each step joins and cleans the whole path +// so far, so the fold is quadratic in the steps, and a 480 KB payload of +// them took 10.9 s against the hook's 5 s timeout. 64 is far past any line +// an agent writes -- one or two steps is the norm -- and folds in well under +// a millisecond. +const MaxLeadingCd = 64 + +// The longest leading run leadingDirectories reads, MaxLeadingCd+1 steps of +// three tokens, lies inside the token bound: this fails to compile otherwise, +// since a run the bound cut short is not where the line ran. +const _ = uint(maxTokens - 3*(MaxLeadingCd+1)) + +// leadingSteps is how many leading `cd DIR &&` steps LeadingDirectory folds +// onto a call's cwd for a line whose leading directories are dirs: the cds +// on it that the call's cwd digest already holds. None past MaxLeadingCd, +// where the hook records the directory as unknown and the digest holds none +// of them. +func leadingSteps(dirs []string) int { + if len(dirs) > MaxLeadingCd { + return 0 + } + return len(dirs) +} + +// leadingDirectories is LeadingDirectory over the line cmd. +// +// A line cut at the token bound (errTokenBound) is read to the bound, since +// its leading steps come first: the most this reads is MaxLeadingCd+1 steps +// of three tokens, far inside the bound (see the constant below), so the cut +// never falls in the run. Giving up there instead kept the payload's cwd for +// `cd sub && python3 - </dev/null cd b` and `(cd b` change directory too, and + // leadingCd refuses them. + k, ok := programToken(toks[i:], false) + if !ok { + return dirs, len(dirs) > 0 + } + switch toks[i+k].text { + case "cd", "pushd", "popd": + default: + return dirs, len(dirs) > 0 + } + dir, ok := leadingCd(toks[i:]) + if !ok { + return nil, false + } + dirs = append(dirs, dir) + if len(dirs) > MaxLeadingCd { + // One past the bound says the line is past it; the + // rest would be read for nothing. + break + } + } + return dirs, len(dirs) > 0 +} + +// leadingCd reports the DIR of a plain literal `cd DIR &&` at the start of +// toks, by LeadingDirectory's rules. +func leadingCd(toks []token) (string, bool) { + if len(toks) < 3 { + return "", false + } + cd, dir, and := toks[0], toks[1], toks[2] + if cd.text != "cd" || cd.quotedAt >= 0 || cd.opaque { + return "", false + } + if dir.meta || dir.quotedAt >= 0 || dir.opaque || dir.ticks > 0 || dir.nlBefore || isComment(dir) || + dir.text == "" || strings.HasPrefix(dir.text, "-") || strings.HasPrefix(dir.text, "~") || + strings.ContainsAny(dir.text, "$`*?[{\\") { + return "", false + } + if !and.meta || and.text != "&&" || and.nlBefore { + return "", false + } + return dir.text, true +} diff --git a/internal/shape/program.go b/internal/shape/program.go new file mode 100644 index 00000000..faa91c2e --- /dev/null +++ b/internal/shape/program.go @@ -0,0 +1,404 @@ +package shape + +import ( + "path" + "strings" +) + +// programToken finds the index of the token naming the program, if one can be +// told for certain. uncertain: the tokens stop where the lexer could no longer +// vouch for its reading (errUncertain), so a command still open there has an +// end nobody saw. +// +// It skips only what it parses completely, and names nothing -- returns false +// -- at the first thing it does not. The skipped things: a leading assignment +// (FOO=bar, FOO+=bar, arr[i]=bar); a separator or a subshell's `(`, after +// which the next word is in command position; a redirection with its whole +// operator and its target; and `{`, the brace-group keyword, where a command +// starts. +// +// Measured on a real session before this existed: three of twenty-six +// declarations recorded a "program" of `&&`, `(` or nothing -- about one in +// eight of the single field that is supposed to say what ran. +// +// The first two versions of this took the next word after whatever they +// skipped, and two adversarial reviews found 190 lines where that word was +// data: an array element, an arithmetic operand, a here-document's body, a +// redirect target under an operator spelling it did not know (zsh's >! and +// >>|), and above all a fragment of a word this tokenizer split where the +// shell does not -- "$(cat "a b")" is one shell word and three tokens here, so +// skipping "one target" left the rest of it to be read as the program. Fifty +// of those leaked on main as well. So the rule is the other way round: any +// word whose extent this tokenizer cannot vouch for (token.opaque), and +// anything else not parsed completely, ends the search with null. Null already +// means "we could not tell" and is read that way; a word from inside a +// construct is read as a fact. +func programToken(toks []token, uncertain bool) (int, bool) { + at := func(j int) token { + if j < len(toks) { + return toks[j] + } + return token{quotedAt: -1} + } + isOp := func(j int, op string) bool { t := at(j); return t.meta && t.text == op } + + start := true // command position: the line's start, after a separator, `(` or `{` + for i := 0; i < len(toks); i++ { + t := toks[i] + if t.meta { + switch { + case t.text == "<" && isNumericGlob(at(i+1)) && at(i+2).meta && strings.HasPrefix(at(i+2).text, ">"): + // zsh's numeric glob, <1-9> or <->: one word to zsh, two + // redirects here. + return 0, false + case t.text == "<<" && !isOp(i+1, "<"): + // A here-document. Its body follows on the next lines, up to a + // delimiter line this search does not look for, so a word + // after it may be body text. + return 0, false + case isRedirect(t.text): + end, ok := redirectEnd(toks, i) + if !ok { + return 0, false + } + i, start = end, false + continue + case t.text == "(" && isOp(i+1, "("): + // `((`: arithmetic. Its operands are not commands. + return 0, false + case t.text == ")": + // A close this search did not see open. + return 0, false + } + start = true // a separator, or a subshell's `(` + continue + } + + switch { + case t.opaque, isComment(t): + return 0, false + case t.text == "{" && t.quotedAt < 0 && start: + // The brace keyword, which only a command's first word can be. + continue + case isRedirect(at(i+1).text) && at(i+1).meta && isFDPrefix(t): + // `2>` or `{fd}>`: an fd number or variable written against its + // redirect, or -- with a blank before the `>` -- a command named + // 2. The search does not read on past either to a command after + // it: an fd prefix names nothing. + return 0, false + case isShellAssignment(t): + if isOp(i+1, "(") { + // An array's elements. + return 0, false + } + start = false + continue + } + if isOp(i+1, "(") || strings.ContainsAny(t.text, "[]{}()") && !plainPunctuation[t.text] { + // `name()` defines a function rather than running one; a bracket + // or brace inside a word is a subscript, a glob or an expansion + // the tokenizer may have split. + return 0, false + } + if eq := strings.IndexByte(t.text, '='); eq >= 0 && (t.quotedAt < 0 || t.quotedAt > eq) { + // An `=` the shells read differently: café=x and 1=x are + // assignments to zsh and commands to bash. + return 0, false + } + if definesFunctions(toks, i, uncertain) { + return 0, false + } + if !plainWord(t.text) || runsOn(toks, i) { + return 0, false + } + return i, true + } + return 0, false +} + +// pastDirectoryChange moves the program from a leading `cd DIR &&` or +// `cd DIR;` to the command that follows it. +// +// `cd` is where a command runs, not what it runs. Measured on a real session: +// 1,397 of 1,495 shell calls were `cd … && `, so reporting the first +// word said "cd" for nearly everything and "by program" said nothing. Only `&&` +// and `;` are followed: a pipe, `||` or `&` after `cd` is left as it was, +// because what follows those is not simply the next command. If the command +// after `cd` cannot be told, neither can the program -- the answer is null, +// not "cd", which would be a confident wrong answer. +// +// The separator is found by commandEnd, the scan that decides where a +// command ends for definesFunctions, so a `;` inside `$( )` or backticks ends +// nothing here either. The command after it is searched by programToken and +// held to every rule the line's first command is. A comment before the +// separator makes it comment text, and `cd` is then the whole command. +// +// It is a loop, one step per cd, rather than a call per cd: a line of tens +// of thousands of them is within the token bound, and its depth is the +// input's to choose (RES-10, CWE-674). +func pastDirectoryChange(toks []token, i int, uncertain bool) (int, bool) { + for toks[i].text == "cd" && toks[i].quotedAt < 0 { + sep, ok := commandEnd(toks, i, uncertain) + if !ok { + return 0, false + } + if sep < 0 || toks[sep].text != "&&" && toks[sep].text != ";" { + return i, true + } + for j := i + 1; j < sep; j++ { + if isComment(toks[j]) { + return i, true + } + } + k, ok := programToken(toks[sep+1:], uncertain) + if !ok { + return 0, false + } + i = sep + 1 + k + } + return i, true +} + +// plainWord reports whether a word in command position is one the search can +// name as it stands: the text the shell runs, not text it computes a command +// from, and not a word the tokenizer ended where the shell does not. +// +// Each rule gives up a program some line really had, to be certain of the +// rest. $EDITOR file and "$PYTHON" x.py name nothing now, which is the honest +// answer: the command is whatever the variable holds. +// +// A word that can never name a program is not a command word either, and +// path.Base would otherwise make one of it. +func plainWord(s string) bool { + switch { + case s == "": + // '' or "": the shell runs nothing by that name, and path.Base + // named it `.`, which reads as the source builtin. + return false + case strings.HasSuffix(s, "/"): + // A directory, which execve refuses whatever it holds; path.Base + // named its last component. So is a word ending in `/` that runs + // into an operator, the front of a directory the shell reads on. + return false + case strings.HasPrefix(s, "~") && !strings.Contains(s, "/"): + // ~user, ~+ and ~- expand to a directory. ~/bin/tool and + // ~user/bin/tool name a file in one, and still name it. + return false + case s != "." && (path.Base(s) == "." || path.Base(s) == ".."): + // A path ending in . or .. is a directory too, and path.Base named + // the dot. A lone `.` is the source builtin, a command like any + // other. + return false + case strings.HasPrefix(s, "%"): + // A jobspec: %1 or %vim resumes that job, as fg does, and names + // nothing but the job. + return false + } + digits := true + for j := 0; j < len(s); j++ { + c := s[j] + switch { + case c < 0x21 || c > 0x7e: + // Outside printable ASCII. A quoted word with a blank or a + // newline in it is a whole command line to the shell -- + // "cat /etc/passwd" quoted once named "passwd" -- and a byte + // the shell does not split on is the same thing unquoted: + // U+00A0 joins git push origin x into one word, arguments and + // all. + return false + case c == '$': + // An expansion. zsh applies colon modifiers to an unbraced + // parameter, so $x:gs/SECRET// runs $x with SECRET removed, and + // path.Base read the substitution's delimiters as a path. + return false + case c == '*' || c == '?': + // A glob: the command is whichever file matches it. + return false + case c == '~' && j > 0: + // zsh's glob exclusion under EXTENDED_GLOB, whose right side is + // the pattern excluded. Only a leading `~` is a home directory. + return false + case c == '^' || c == '#': + // zsh's other EXTENDED_GLOB operators: ^ negates the pattern + // after it and # repeats the one before. /usr/bin/^SECRET named + // ^SECRET. + return false + } + digits = digits && c >= '0' && c <= '9' + } + // All digits: an fd number the tokenizer split from its redirect, as in + // 2&>f, or a word after a separator that was really a redirect's target. + return !digits +} + +// runsOn reports whether the word at i runs on, with nothing between, into +// what the shell reads as more of the same word: a process substitution, <( ) +// or >( ), which bash and zsh both keep inside a word, or a zsh numeric glob, +// or <->. The word the shell runs is then acme-merger/dev/fd/63 or +// acme-1/run.sh, and the token here only the front of it. +func runsOn(toks []token, i int) bool { + if i+2 >= len(toks) || !toks[i+1].meta || !toks[i+1].glued || !toks[i+2].glued { + return false + } + op := toks[i+1].text + if op != "<" && op != ">" { + return false + } + next := toks[i+2] + return next.meta && next.text == "(" || op == "<" && isNumericGlob(next) +} + +// plainPunctuation are the command names made of brackets or braces: the test +// builtins, and a brace that is not in command-start position -- after an +// assignment or a redirect the shell runs `{` as an ordinary command. +var plainPunctuation = map[string]bool{"[": true, "[[": true, "{": true, "}": true} + +// isNumericGlob reports whether an unquoted word is the inside of a zsh +// numeric glob: digits, a dash, digits, either side possibly empty. +func isNumericGlob(t token) bool { + if t.meta || t.quotedAt >= 0 || !strings.Contains(t.text, "-") { + return false + } + for j := 0; j < len(t.text); j++ { + if c := t.text[j]; c != '-' && (c < '0' || c > '9') { + return false + } + } + return strings.Count(t.text, "-") == 1 +} + +// definesFunctions reports whether the word at i cannot be named as the +// command it begins: the command runs into an empty `()`, or the search for +// one meets what it cannot read past. +// +// zsh's `f g () { ... }` defines functions f and g and runs neither. Nor does +// `f >x g () { ... }` or `f $(date) () { ... }`: a redirection, an expansion +// or a glob between the names does not end the command. A separator does, +// and so does a newline -- but not one inside a group, which is skipped to +// its close first: a paren that does not close at once opens one, and so +// covers $( ), <( ), >( ) and a glob's @( ); and backticks form one. +// `f $(x; y) () { ... }` is one command, and so a function definition. +// +// Nothing is certain, and nothing is named, when the scan cannot find where +// the command ends: a group that never closes; a redirection that is a +// syntax error; a group whose depth the tokens do not show (see +// unknownDepth); a ${ the word does not close, which is a group of its own in +// bash 5.3 and zsh (${ cmd; }) or hides a paren (${x#)}); and tokens that stop +// where the lexer's reading did (uncertain) with the command still open. +func definesFunctions(toks []token, i int, uncertain bool) bool { + _, ok := commandEnd(toks, i, uncertain) + return !ok +} + +// commandEnd finds where the command whose word is at i ends, by the scan +// definesFunctions describes. sep is the index of the separator that ends it, +// or -1 when a newline or the end of the line does; ok is false where +// definesFunctions names nothing. +func commandEnd(toks []token, i int, uncertain bool) (sep int, ok bool) { + var ( + open []byte // the groups around the token, innermost last: '(' or '`' + heredoc bool // a here-document was passed: its body starts at the next newline + ) + for j := i + 1; j < len(toks); j++ { + t := toks[j] + if openBrace(t) { + return 0, false + } + if len(open) > 0 { + if unknownDepth(toks, j, heredoc) { + return 0, false + } + open = inGroup(open, t) + continue + } + switch { + case t.nlBefore: + return -1, true + case t.ticks%2 == 1: + open = append(open, '`') + case !t.meta: + case t.text == "(": + if j+1 < len(toks) && toks[j+1].meta && toks[j+1].text == ")" { + return 0, false + } + open = append(open, '(') + case t.text == "&" && j+1 < len(toks) && toks[j+1].meta && toks[j+1].glued && strings.HasPrefix(toks[j+1].text, ">"): + // &> and &>>: a redirection, not the background separator. + j++ + fallthrough + case isRedirect(t.text): + end := operatorEnd(toks, j) + if noTarget(toks, end) { + // A syntax error to both shells: nothing on the line runs. + return 0, false + } + heredoc = heredoc || hereDoc(toks, j) + j = end + case t.text == ";" || t.text == "&&" || t.text == "||" || t.text == "|" || t.text == "&": + return j, true + } + } + if len(open) > 0 || uncertain { + return 0, false + } + return -1, true +} + +// unknownDepth reports a token inside a group from which the group's depth +// cannot be told, because what follows may hold a paren, a quote or a +// backtick that counts for nothing: a comment; a case statement, whose +// patterns end in a `)` that closes no group; a here-document, whose body is +// text; and, once a here-document has been passed (heredoc), a newline, after +// which its body may begin. +func unknownDepth(toks []token, j int, heredoc bool) bool { + t := toks[j] + switch { + case isComment(t): + return true + case !t.meta && t.quotedAt < 0 && t.text == "case": + return true + case hereDoc(toks, j): + return true + case heredoc && t.nlBefore: + return true + } + return false +} + +// hereDoc reports a here-document operator at j: `<<`, and not the +// here-string `<<<`, which arrives as `<<` and a glued `<`. +func hereDoc(toks []token, j int) bool { + if !toks[j].meta || toks[j].text != "<<" { + return false + } + next := j + 1 + return next >= len(toks) || !(toks[next].meta && toks[next].glued && toks[next].text == "<") +} + +// openBrace reports a word holding a ${ that it does not close: the tokenizer +// split the expansion, so its end is in a later token that the shell reads as +// part of it, or never came. +func openBrace(t token) bool { + k := strings.Index(t.text, "${") + return t.opaque && k >= 0 && strings.Count(t.text[k:], "{") > strings.Count(t.text[k:], "}") +} + +// inGroup returns the groups open after the token t, given those open before +// it. Inside backticks only a backtick counts: the first live one closes them, +// whatever parens came between. Elsewhere a paren opens or closes by depth, +// and a backtick opens a group inside the paren's. +func inGroup(open []byte, t token) []byte { + top := open[len(open)-1] + switch { + case t.ticks%2 == 1 && top == '`': + return open[:len(open)-1] + case t.ticks%2 == 1: + return append(open, '`') + case top == '`' || !t.meta: + case t.text == "(": + return append(open, '(') + case t.text == ")": + return open[:len(open)-1] + } + return open +} diff --git a/internal/shape/shape.go b/internal/shape/shape.go index 0aebaf95..d504c0ed 100644 --- a/internal/shape/shape.go +++ b/internal/shape/shape.go @@ -1,7 +1,8 @@ // Package shape derives the recorded shape of a tool call. // // This package is the only one that looks inside tool_input, and it exists so -// that exactly one file has to be audited to believe the no-content guarantee. +// that exactly one package has to be audited to believe the no-content +// guarantee. // Everything it returns is either drawn from a closed vocabulary, a count, or a // keyed digest. No substring of the input reaches the result. // @@ -20,10 +21,6 @@ import ( "encoding/json" "hash" "path" - "slices" - "strings" - - "github.com/altrace-dev-role/rashomon/internal/jsonscan" ) // Verb classes. A closed set: an unrecognized program is "execute" and an @@ -290,1514 +287,3 @@ func canonicalDigest(key []byte, toolName string, toolInput json.RawMessage) str } return hex.EncodeToString(m.Sum(nil)) } - -// programToken finds the index of the token naming the program, if one can be -// told for certain. uncertain: the tokens stop where the lexer could no longer -// vouch for its reading (errUncertain), so a command still open there has an -// end nobody saw. -// -// It skips only what it parses completely, and names nothing -- returns false -// -- at the first thing it does not. The skipped things: a leading assignment -// (FOO=bar, FOO+=bar, arr[i]=bar); a separator or a subshell's `(`, after -// which the next word is in command position; a redirection with its whole -// operator and its target; and `{`, the brace-group keyword, where a command -// starts. -// -// Measured on a real session before this existed: three of twenty-six -// declarations recorded a "program" of `&&`, `(` or nothing -- about one in -// eight of the single field that is supposed to say what ran. -// -// The first two versions of this took the next word after whatever they -// skipped, and two adversarial reviews found 190 lines where that word was -// data: an array element, an arithmetic operand, a here-document's body, a -// redirect target under an operator spelling it did not know (zsh's >! and -// >>|), and above all a fragment of a word this tokenizer split where the -// shell does not -- "$(cat "a b")" is one shell word and three tokens here, so -// skipping "one target" left the rest of it to be read as the program. Fifty -// of those leaked on main as well. So the rule is the other way round: any -// word whose extent this tokenizer cannot vouch for (token.opaque), and -// anything else not parsed completely, ends the search with null. Null already -// means "we could not tell" and is read that way; a word from inside a -// construct is read as a fact. -func programToken(toks []token, uncertain bool) (int, bool) { - at := func(j int) token { - if j < len(toks) { - return toks[j] - } - return token{quotedAt: -1} - } - isOp := func(j int, op string) bool { t := at(j); return t.meta && t.text == op } - - start := true // command position: the line's start, after a separator, `(` or `{` - for i := 0; i < len(toks); i++ { - t := toks[i] - if t.meta { - switch { - case t.text == "<" && isNumericGlob(at(i+1)) && at(i+2).meta && strings.HasPrefix(at(i+2).text, ">"): - // zsh's numeric glob, <1-9> or <->: one word to zsh, two - // redirects here. - return 0, false - case t.text == "<<" && !isOp(i+1, "<"): - // A here-document. Its body follows on the next lines, up to a - // delimiter line this search does not look for, so a word - // after it may be body text. - return 0, false - case isRedirect(t.text): - end, ok := redirectEnd(toks, i) - if !ok { - return 0, false - } - i, start = end, false - continue - case t.text == "(" && isOp(i+1, "("): - // `((`: arithmetic. Its operands are not commands. - return 0, false - case t.text == ")": - // A close this search did not see open. - return 0, false - } - start = true // a separator, or a subshell's `(` - continue - } - - switch { - case t.opaque, isComment(t): - return 0, false - case t.text == "{" && t.quotedAt < 0 && start: - // The brace keyword, which only a command's first word can be. - continue - case isRedirect(at(i+1).text) && at(i+1).meta && isFDPrefix(t): - // `2>` or `{fd}>`: an fd number or variable written against its - // redirect, or -- with a blank before the `>` -- a command named - // 2. The search does not read on past either to a command after - // it: an fd prefix names nothing. - return 0, false - case isShellAssignment(t): - if isOp(i+1, "(") { - // An array's elements. - return 0, false - } - start = false - continue - } - if isOp(i+1, "(") || strings.ContainsAny(t.text, "[]{}()") && !plainPunctuation[t.text] { - // `name()` defines a function rather than running one; a bracket - // or brace inside a word is a subscript, a glob or an expansion - // the tokenizer may have split. - return 0, false - } - if eq := strings.IndexByte(t.text, '='); eq >= 0 && (t.quotedAt < 0 || t.quotedAt > eq) { - // An `=` the shells read differently: café=x and 1=x are - // assignments to zsh and commands to bash. - return 0, false - } - if definesFunctions(toks, i, uncertain) { - return 0, false - } - if !plainWord(t.text) || runsOn(toks, i) { - return 0, false - } - return i, true - } - return 0, false -} - -// pastDirectoryChange moves the program from a leading `cd DIR &&` or -// `cd DIR;` to the command that follows it. -// -// `cd` is where a command runs, not what it runs. Measured on a real session: -// 1,397 of 1,495 shell calls were `cd … && `, so reporting the first -// word said "cd" for nearly everything and "by program" said nothing. Only `&&` -// and `;` are followed: a pipe, `||` or `&` after `cd` is left as it was, -// because what follows those is not simply the next command. If the command -// after `cd` cannot be told, neither can the program -- the answer is null, -// not "cd", which would be a confident wrong answer. -// -// The separator is found by commandEnd, the scan that decides where a -// command ends for definesFunctions, so a `;` inside `$( )` or backticks ends -// nothing here either. The command after it is searched by programToken and -// held to every rule the line's first command is. A comment before the -// separator makes it comment text, and `cd` is then the whole command. -// -// It is a loop, one step per cd, rather than a call per cd: a line of tens -// of thousands of them is within the token bound, and its depth is the -// input's to choose (RES-10, CWE-674). -func pastDirectoryChange(toks []token, i int, uncertain bool) (int, bool) { - for toks[i].text == "cd" && toks[i].quotedAt < 0 { - sep, ok := commandEnd(toks, i, uncertain) - if !ok { - return 0, false - } - if sep < 0 || toks[sep].text != "&&" && toks[sep].text != ";" { - return i, true - } - for j := i + 1; j < sep; j++ { - if isComment(toks[j]) { - return i, true - } - } - k, ok := programToken(toks[sep+1:], uncertain) - if !ok { - return 0, false - } - i = sep + 1 + k - } - return i, true -} - -// LeadingDirectory reports the directories a shell call's line moves through -// before anything else runs, when the line begins with plain literal -// `cd DIR &&` steps: each DIR as written, in order, which the caller folds -// onto the call's cwd. The command after each `&&` runs only if its cd -// succeeded, so the runner starts where the last one leads. -// -// Only a word the shell takes as it stands: nothing it expands or unquotes -// (`$`, backticks, a glob, `~`, quotes or an escape), not `-`, which is the -// previous directory, and not a comment. `cd DIR;` is left out, since what -// follows a `;` runs whether the cd succeeded or not. A step in the leading -// run whose command, past any assignments, redirections or an opening ( or { -// in front of it, is cd, pushd or popd and is not such a cd spoils the run, -// since the directory it leads to is not on the line. So a `cd -` is refused -// anywhere in the leading run, even when an earlier step names its target: -// `cd /a && cd b && cd - && go test` loses a pair that way, but it never -// forms a false one. Anything else reports false, and the call keeps the -// payload's cwd. -func LeadingDirectory(toolName string, toolInput json.RawMessage) ([]string, bool) { - if verbForTool(toolName) != VerbExecute { - return nil, false - } - cmd, ok := commandField(toolInput) - if !ok || controlByte(cmd) { - return nil, false - } - return leadingDirectories(cmd) -} - -// MaxLeadingCd is how many leading `cd DIR &&` steps a call's start -// directory is folded from (RES-10). The hook folds no more and records the -// directory as unknown past them: each step joins and cleans the whole path -// so far, so the fold is quadratic in the steps, and a 480 KB payload of -// them took 10.9 s against the hook's 5 s timeout. 64 is far past any line -// an agent writes -- one or two steps is the norm -- and folds in well under -// a millisecond. -const MaxLeadingCd = 64 - -// The longest leading run leadingDirectories reads, MaxLeadingCd+1 steps of -// three tokens, lies inside the token bound: this fails to compile otherwise, -// since a run the bound cut short is not where the line ran. -const _ = uint(maxTokens - 3*(MaxLeadingCd+1)) - -// leadingSteps is how many leading `cd DIR &&` steps LeadingDirectory folds -// onto a call's cwd for a line whose leading directories are dirs: the cds -// on it that the call's cwd digest already holds. None past MaxLeadingCd, -// where the hook records the directory as unknown and the digest holds none -// of them. -func leadingSteps(dirs []string) int { - if len(dirs) > MaxLeadingCd { - return 0 - } - return len(dirs) -} - -// leadingDirectories is LeadingDirectory over the line cmd. -// -// A line cut at the token bound (errTokenBound) is read to the bound, since -// its leading steps come first: the most this reads is MaxLeadingCd+1 steps -// of three tokens, far inside the bound (see the constant below), so the cut -// never falls in the run. Giving up there instead kept the payload's cwd for -// `cd sub && python3 - </dev/null cd b` and `(cd b` change directory too, and - // leadingCd refuses them. - k, ok := programToken(toks[i:], false) - if !ok { - return dirs, len(dirs) > 0 - } - switch toks[i+k].text { - case "cd", "pushd", "popd": - default: - return dirs, len(dirs) > 0 - } - dir, ok := leadingCd(toks[i:]) - if !ok { - return nil, false - } - dirs = append(dirs, dir) - if len(dirs) > MaxLeadingCd { - // One past the bound says the line is past it; the - // rest would be read for nothing. - break - } - } - return dirs, len(dirs) > 0 -} - -// leadingCd reports the DIR of a plain literal `cd DIR &&` at the start of -// toks, by LeadingDirectory's rules. -func leadingCd(toks []token) (string, bool) { - if len(toks) < 3 { - return "", false - } - cd, dir, and := toks[0], toks[1], toks[2] - if cd.text != "cd" || cd.quotedAt >= 0 || cd.opaque { - return "", false - } - if dir.meta || dir.quotedAt >= 0 || dir.opaque || dir.ticks > 0 || dir.nlBefore || isComment(dir) || - dir.text == "" || strings.HasPrefix(dir.text, "-") || strings.HasPrefix(dir.text, "~") || - strings.ContainsAny(dir.text, "$`*?[{\\") { - return "", false - } - if !and.meta || and.text != "&&" || and.nlBefore { - return "", false - } - return dir.text, true -} - -// plainWord reports whether a word in command position is one the search can -// name as it stands: the text the shell runs, not text it computes a command -// from, and not a word the tokenizer ended where the shell does not. -// -// Each rule gives up a program some line really had, to be certain of the -// rest. $EDITOR file and "$PYTHON" x.py name nothing now, which is the honest -// answer: the command is whatever the variable holds. -// -// A word that can never name a program is not a command word either, and -// path.Base would otherwise make one of it. -func plainWord(s string) bool { - switch { - case s == "": - // '' or "": the shell runs nothing by that name, and path.Base - // named it `.`, which reads as the source builtin. - return false - case strings.HasSuffix(s, "/"): - // A directory, which execve refuses whatever it holds; path.Base - // named its last component. So is a word ending in `/` that runs - // into an operator, the front of a directory the shell reads on. - return false - case strings.HasPrefix(s, "~") && !strings.Contains(s, "/"): - // ~user, ~+ and ~- expand to a directory. ~/bin/tool and - // ~user/bin/tool name a file in one, and still name it. - return false - case s != "." && (path.Base(s) == "." || path.Base(s) == ".."): - // A path ending in . or .. is a directory too, and path.Base named - // the dot. A lone `.` is the source builtin, a command like any - // other. - return false - case strings.HasPrefix(s, "%"): - // A jobspec: %1 or %vim resumes that job, as fg does, and names - // nothing but the job. - return false - } - digits := true - for j := 0; j < len(s); j++ { - c := s[j] - switch { - case c < 0x21 || c > 0x7e: - // Outside printable ASCII. A quoted word with a blank or a - // newline in it is a whole command line to the shell -- - // "cat /etc/passwd" quoted once named "passwd" -- and a byte - // the shell does not split on is the same thing unquoted: - // U+00A0 joins git push origin x into one word, arguments and - // all. - return false - case c == '$': - // An expansion. zsh applies colon modifiers to an unbraced - // parameter, so $x:gs/SECRET// runs $x with SECRET removed, and - // path.Base read the substitution's delimiters as a path. - return false - case c == '*' || c == '?': - // A glob: the command is whichever file matches it. - return false - case c == '~' && j > 0: - // zsh's glob exclusion under EXTENDED_GLOB, whose right side is - // the pattern excluded. Only a leading `~` is a home directory. - return false - case c == '^' || c == '#': - // zsh's other EXTENDED_GLOB operators: ^ negates the pattern - // after it and # repeats the one before. /usr/bin/^SECRET named - // ^SECRET. - return false - } - digits = digits && c >= '0' && c <= '9' - } - // All digits: an fd number the tokenizer split from its redirect, as in - // 2&>f, or a word after a separator that was really a redirect's target. - return !digits -} - -// runsOn reports whether the word at i runs on, with nothing between, into -// what the shell reads as more of the same word: a process substitution, <( ) -// or >( ), which bash and zsh both keep inside a word, or a zsh numeric glob, -// or <->. The word the shell runs is then acme-merger/dev/fd/63 or -// acme-1/run.sh, and the token here only the front of it. -func runsOn(toks []token, i int) bool { - if i+2 >= len(toks) || !toks[i+1].meta || !toks[i+1].glued || !toks[i+2].glued { - return false - } - op := toks[i+1].text - if op != "<" && op != ">" { - return false - } - next := toks[i+2] - return next.meta && next.text == "(" || op == "<" && isNumericGlob(next) -} - -// plainPunctuation are the command names made of brackets or braces: the test -// builtins, and a brace that is not in command-start position -- after an -// assignment or a redirect the shell runs `{` as an ordinary command. -var plainPunctuation = map[string]bool{"[": true, "[[": true, "{": true, "}": true} - -// isNumericGlob reports whether an unquoted word is the inside of a zsh -// numeric glob: digits, a dash, digits, either side possibly empty. -func isNumericGlob(t token) bool { - if t.meta || t.quotedAt >= 0 || !strings.Contains(t.text, "-") { - return false - } - for j := 0; j < len(t.text); j++ { - if c := t.text[j]; c != '-' && (c < '0' || c > '9') { - return false - } - } - return strings.Count(t.text, "-") == 1 -} - -// definesFunctions reports whether the word at i cannot be named as the -// command it begins: the command runs into an empty `()`, or the search for -// one meets what it cannot read past. -// -// zsh's `f g () { ... }` defines functions f and g and runs neither. Nor does -// `f >x g () { ... }` or `f $(date) () { ... }`: a redirection, an expansion -// or a glob between the names does not end the command. A separator does, -// and so does a newline -- but not one inside a group, which is skipped to -// its close first: a paren that does not close at once opens one, and so -// covers $( ), <( ), >( ) and a glob's @( ); and backticks form one. -// `f $(x; y) () { ... }` is one command, and so a function definition. -// -// Nothing is certain, and nothing is named, when the scan cannot find where -// the command ends: a group that never closes; a redirection that is a -// syntax error; a group whose depth the tokens do not show (see -// unknownDepth); a ${ the word does not close, which is a group of its own in -// bash 5.3 and zsh (${ cmd; }) or hides a paren (${x#)}); and tokens that stop -// where the lexer's reading did (uncertain) with the command still open. -func definesFunctions(toks []token, i int, uncertain bool) bool { - _, ok := commandEnd(toks, i, uncertain) - return !ok -} - -// commandEnd finds where the command whose word is at i ends, by the scan -// definesFunctions describes. sep is the index of the separator that ends it, -// or -1 when a newline or the end of the line does; ok is false where -// definesFunctions names nothing. -func commandEnd(toks []token, i int, uncertain bool) (sep int, ok bool) { - var ( - open []byte // the groups around the token, innermost last: '(' or '`' - heredoc bool // a here-document was passed: its body starts at the next newline - ) - for j := i + 1; j < len(toks); j++ { - t := toks[j] - if openBrace(t) { - return 0, false - } - if len(open) > 0 { - if unknownDepth(toks, j, heredoc) { - return 0, false - } - open = inGroup(open, t) - continue - } - switch { - case t.nlBefore: - return -1, true - case t.ticks%2 == 1: - open = append(open, '`') - case !t.meta: - case t.text == "(": - if j+1 < len(toks) && toks[j+1].meta && toks[j+1].text == ")" { - return 0, false - } - open = append(open, '(') - case t.text == "&" && j+1 < len(toks) && toks[j+1].meta && toks[j+1].glued && strings.HasPrefix(toks[j+1].text, ">"): - // &> and &>>: a redirection, not the background separator. - j++ - fallthrough - case isRedirect(t.text): - end := operatorEnd(toks, j) - if noTarget(toks, end) { - // A syntax error to both shells: nothing on the line runs. - return 0, false - } - heredoc = heredoc || hereDoc(toks, j) - j = end - case t.text == ";" || t.text == "&&" || t.text == "||" || t.text == "|" || t.text == "&": - return j, true - } - } - if len(open) > 0 || uncertain { - return 0, false - } - return -1, true -} - -// unknownDepth reports a token inside a group from which the group's depth -// cannot be told, because what follows may hold a paren, a quote or a -// backtick that counts for nothing: a comment; a case statement, whose -// patterns end in a `)` that closes no group; a here-document, whose body is -// text; and, once a here-document has been passed (heredoc), a newline, after -// which its body may begin. -func unknownDepth(toks []token, j int, heredoc bool) bool { - t := toks[j] - switch { - case isComment(t): - return true - case !t.meta && t.quotedAt < 0 && t.text == "case": - return true - case hereDoc(toks, j): - return true - case heredoc && t.nlBefore: - return true - } - return false -} - -// hereDoc reports a here-document operator at j: `<<`, and not the -// here-string `<<<`, which arrives as `<<` and a glued `<`. -func hereDoc(toks []token, j int) bool { - if !toks[j].meta || toks[j].text != "<<" { - return false - } - next := j + 1 - return next >= len(toks) || !(toks[next].meta && toks[next].glued && toks[next].text == "<") -} - -// openBrace reports a word holding a ${ that it does not close: the tokenizer -// split the expansion, so its end is in a later token that the shell reads as -// part of it, or never came. -func openBrace(t token) bool { - k := strings.Index(t.text, "${") - return t.opaque && k >= 0 && strings.Count(t.text[k:], "{") > strings.Count(t.text[k:], "}") -} - -// inGroup returns the groups open after the token t, given those open before -// it. Inside backticks only a backtick counts: the first live one closes them, -// whatever parens came between. Elsewhere a paren opens or closes by depth, -// and a backtick opens a group inside the paren's. -func inGroup(open []byte, t token) []byte { - top := open[len(open)-1] - switch { - case t.ticks%2 == 1 && top == '`': - return open[:len(open)-1] - case t.ticks%2 == 1: - return append(open, '`') - case top == '`' || !t.meta: - case t.text == "(": - return append(open, '(') - case t.text == ")": - return open[:len(open)-1] - } - return open -} - -// joinContinuations removes every backslash-newline the shell removes before -// it splits a line into words: outside quotes and inside double quotes, but -// not inside single quotes, where a backslash is literal. It returns the -// line with them removed and the offsets in it at which it removed one, -// ascending: the byte there is the one that followed it. Its quote tracking -// is flat, which is right outside a $( ) and may be wrong inside one; see -// comsubEnd. -// -// It keeps the first maxJoins offsets and no more (joined.lost): one per -// continuation, 8 MiB of `a\` kept 2.8 million of them, 22 MiB, -// in each reading of the line. The line itself is joined whole. -func joinContinuations(s string) joined { - return joinLines(s, false) -} - -// maxJoins is how many offsets joinContinuations keeps: as many as the -// token bound lets a line have tokens (RES-9), 340 KB of them. Past them the -// readers that ask where a backslash-newline was removed, a here-document's -// body and a $( ) inside double quotes, give up rather than guess. -const maxJoins = maxTokens - -// joinLines is joinContinuations; with comments, an unquoted # at the start -// of a word runs to the end of its line, as a comment does to the shell, and -// a backslash at its end continues nothing: `# run the tests \` and then -// `make test | tail` on the next line runs make. -func joinLines(s string, comments bool) joined { - n := strings.Count(s, "\\\n") - if n == 0 { - return joined{s: s} - } - var ( - b strings.Builder - joins = make([]int, 0, min(n, maxJoins)) - lost bool - ) - // The line loses bytes, except where a comment or a single quote keeps - // a backslash-newline, so it is grown once to the line's length. - b.Grow(len(s)) - inSingle, inDouble := false, false - for i := 0; i < len(s); i++ { - c := s[i] - switch { - case comments && c == '#' && !inSingle && !inDouble && wordStart(b.String()): - for ; i < len(s) && s[i] != '\n'; i++ { - b.WriteByte(s[i]) - } - i-- - continue - case inSingle: - if c == '\'' { - inSingle = false - } - case c == '\\' && i+1 < len(s): - if s[i+1] == '\n' { - if len(joins) < maxJoins { - joins = append(joins, b.Len()) - } else { - lost = true - } - i++ - continue - } - b.WriteByte(c) - i++ - c = s[i] - case c == '\'' && !inDouble: - inSingle = true - case c == '"': - inDouble = !inDouble - } - b.WriteByte(c) - } - return joined{s: b.String(), joins: joins, lost: lost} -} - -// wordStart reports whether a word would begin after the text written so -// far: at its start, or after a blank or an operator. -func wordStart(written string) bool { - if written == "" { - return true - } - switch written[len(written)-1] { - case ' ', '\t', '\n', ';', '&', '|', '(', ')', '<', '>': - return true - } - return false -} - -// isComment reports whether a word begins a comment: an unquoted `#` at its -// start. -func isComment(t token) bool { - return strings.HasPrefix(t.text, "#") && t.quotedAt != 0 -} - -// isFDPrefix reports whether an unquoted word could be an fd number (2 in 2>) -// or an fd variable ({fd} in {fd}>). -func isFDPrefix(t token) bool { - if t.quotedAt >= 0 || t.text == "" { - return false - } - if strings.HasPrefix(t.text, "{") && strings.HasSuffix(t.text, "}") { - return true - } - for j := 0; j < len(t.text); j++ { - if t.text[j] < '0' || t.text[j] > '9' { - return false - } - } - return true -} - -// redirectEnd returns the index of the last token of the redirection whose -// operator is at i -- the operator, the rest of an operator the tokenizer -// emitted as more than one token, and the word it redirects to -- or false -// when where the redirection ends cannot be told, or when it is a syntax error -// (noTarget), which leaves nothing on the line to run. -// -// The target is taken only if it is a word, and only if that word is certainly -// the whole target: not an expansion the tokenizer split, not the start of a -// glob or array (a paren glued to it), and not zsh's `!` -- `>! f` is a -// clobber into f in zsh and a redirect into a file named ! in bash, and the -// two disagree about which word after it is the command. -func redirectEnd(toks []token, i int) (int, bool) { - i = operatorEnd(toks, i) - if noTarget(toks, i) { - return 0, false - } - if toks[i+1].meta { - // A paren where the target belongs -- a zsh glob such as (a|b) or - // (x).csv -- or a process substitution, <( ) or >( ), whose operator - // is here and its paren next. Either way the words inside it are not - // in command position for this line. - return 0, false - } - target := toks[i+1] - if target.opaque || target.text == "!" && target.quotedAt < 0 { - return 0, false - } - i++ - if i+1 < len(toks) && toks[i+1].meta && toks[i+1].text == "(" { - return 0, false - } - return i, true -} - -// noTarget reports a redirection whose operator ends at i with no word where -// its target belongs, which both shells reject, so that nothing on the line -// runs. Read past, the word after it was taken for the program, and in -// `> ; /home/alice/secret.csv x` that word is a path. There is no target when: -// -// - the line ends there; -// - the next word is on the next line, where it begins the next command; -// - a comment begins there (`ls > #x`), which runs to the end of the line; -// - an operator comes first -- a separator, `> ;` or `< |`, or another -// redirection, `> > f`, which owns the word after it -- unless it is a -// paren, a glob or process substitution that the callers refuse or skip, -// or a process substitution's own `<` or `>`, as in `cat < <(ls)`. -func noTarget(toks []token, i int) bool { - if i+1 >= len(toks) { - return true - } - next := toks[i+1] - switch { - case next.nlBefore: - return true - case isComment(next): - return true - case !next.meta, next.text == "(": - return false - } - return !procSub(toks, i+1) -} - -// procSub reports a process substitution beginning at j: a `<` or `>` with a -// paren glued to it, which bash and zsh read as a word. -func procSub(toks []token, j int) bool { - op := toks[j].text - if op != "<" && op != ">" || j+1 >= len(toks) { - return false - } - next := toks[j+1] - return next.meta && next.glued && next.text == "(" -} - -// operatorEnd returns the index of the last piece of the redirection operator -// at i. -// -// The tokenizer doubles a metacharacter only when the next byte is the same -// byte, so `>&`, `>|`, `<>`, `<&` and a here-string's `<<<` arrive as two meta -// tokens, and zsh's `>>|`, `>>&`, `>&|` and `>>&|` as two or three. A piece -// counts only glued to the one before it: `>|` is one operator and `> |` is -// two, and the text of the tokens does not say which. With a blank or a -// newline before it, a piece is the next operator, standing where this one's -// target belongs, and noTarget refuses it: `> |` and `>\n&` are a redirect -// with no target and then a separator, and `<< <&` a here-document with no -// delimiter. Joined, the next word read as the target and an argument as the -// program. -func operatorEnd(toks []token, i int) int { - op := toks[i].text - // At most one further piece, except after > and >>, which zsh extends - // by two (>&| and >>&|). A second `<` after `<<` is the next - // redirection, not more of this one. - pieces := 1 - if op == ">" || op == ">>" { - pieces = 2 - } - for n := 0; n < pieces && i+1 < len(toks) && toks[i+1].meta && toks[i+1].glued && continuesRedirect(op, toks[i+1].text); n++ { - i++ - } - return i -} - -// continuesRedirect reports whether next is a further piece of the -// redirection operator op, which the tokenizer emitted as separate tokens. -func continuesRedirect(op, next string) bool { - switch op { - case ">", ">>": - return next == "&" || next == "|" - case "<": - return next == ">" || next == "&" - case "<<": - return next == "<" - } - return false -} - -// isRedirect reports whether a metacharacter token redirects, and so is -// followed by a filename rather than by a command. -func isRedirect(tok string) bool { - switch tok { - case ">", "<", ">>", "<<": - return true - } - return false -} - -func isAssignment(tok string) bool { - i := strings.IndexByte(tok, '=') - if i <= 0 { - return false - } - for j := 0; j < i; j++ { - c := tok[j] - switch { - case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c == '_': - case c >= '0' && c <= '9' && j > 0: - default: - return false - } - } - return true -} - -// isShellAssignment reports whether a word is a variable assignment the shell -// would perform: NAME=value, NAME+=value, or NAME[subscript]=value, with the -// name, the `[` and the `=` unquoted -- quoted, 'FOO=bar' is a command by that -// name. A subscript may itself hold `=` (arr[i==0]=v), so the `=` looked for is -// the one after the subscript's matching `]`. -// -// It is wider than isAssignment, which decides argc and is left as it was so -// that a count stays the count it has always been. Program detection cannot -// afford that narrowness: an assignment form it did not recognise used to be -// returned as the program, value and all (PATH+=:/home/u/dir named "dir"). -func isShellAssignment(t token) bool { - s := t.text - j := 0 - for j < len(s) { - c := s[j] - if c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c == '_' || c >= '0' && c <= '9' && j > 0 { - j++ - continue - } - break - } - if j == 0 || j >= len(s) { - return false - } - if s[j] == '[' { - depth := 0 - k := j - for ; k < len(s); k++ { - if s[k] == '[' { - depth++ - } else if s[k] == ']' { - depth-- - if depth == 0 { - break - } - } - } - if k >= len(s) { - return false - } - j = k + 1 - } - eq := j - if eq < len(s) && s[eq] == '+' { - eq++ - } - if eq >= len(s) || s[eq] != '=' { - return false - } - // Nothing before the `=` may be quoted. Bash decides an assignment on - // the raw word and needs a literal `]` then `=`; a quoted or escaped - // byte anywhere before it -- arr[0]"="x, arr[0\]=x -- makes the word a - // command instead, and skipping it would read its argument as the - // program. A quoted subscript that bash would still accept (arr["k"]=v) - // is refused too: null is the cautious answer there, not a leak. - return t.quotedAt < 0 || t.quotedAt > eq -} - -// ToolVerb is the verb class a tool's name alone gives, before any command -// line is read: what Derive starts from. A shell tool is execute here -// whatever its command runs, so this is the answer for the tool, never for -// the call. -func ToolVerb(name string) string { return verbForTool(name) } - -// readTools are the tools classed read by name alone. It is the one list: -// report's lookups (IsLookup) and the words the report and spend name them -// with are both read from it, through ReadTools. -var readTools = []string{"Read", "Glob", "Grep", "NotebookRead"} - -// ReadTools returns the tools classed read by name alone, in a fresh slice a -// caller may keep or change. -func ReadTools() []string { return slices.Clone(readTools) } - -func verbForTool(name string) string { - if strings.HasPrefix(name, "mcp__") { - return VerbMCP - } - if slices.Contains(readTools, name) { - return VerbRead - } - switch name { - case "Write", "Edit", "MultiEdit", "NotebookEdit": - return VerbWrite - case "WebFetch", "WebSearch": - return VerbNetwork - case "Bash", "BashOutput", "KillShell": - return VerbExecute - case "Task", "Agent": - return VerbAgent - default: - return VerbUnknown - } -} - -// programVerb is a coarse classification and is documented as such. It exists to -// make a declaration readable at a glance, not to be a security boundary: an -// unrecognized program is "execute", which is the truthful answer for anything -// being run. -var programVerb = map[string]string{ - "cat": VerbRead, "less": VerbRead, "more": VerbRead, "head": VerbRead, - "tail": VerbRead, "ls": VerbRead, "find": VerbRead, "grep": VerbRead, - "rg": VerbRead, "wc": VerbRead, "stat": VerbRead, "file": VerbRead, - "tree": VerbRead, "diff": VerbRead, "realpath": VerbRead, "pwd": VerbRead, - - "rm": VerbWrite, "mv": VerbWrite, "cp": VerbWrite, "mkdir": VerbWrite, - "rmdir": VerbWrite, "touch": VerbWrite, "tee": VerbWrite, "ln": VerbWrite, - "chmod": VerbWrite, "chown": VerbWrite, "truncate": VerbWrite, - - "curl": VerbNetwork, "wget": VerbNetwork, "ssh": VerbNetwork, - "scp": VerbNetwork, "rsync": VerbNetwork, "nc": VerbNetwork, - "dig": VerbNetwork, "host": VerbNetwork, "ping": VerbNetwork, - - "git": VerbVCS, "gh": VerbVCS, "hg": VerbVCS, "svn": VerbVCS, - - "npm": VerbPackage, "npx": VerbPackage, "yarn": VerbPackage, - "pnpm": VerbPackage, "pip": VerbPackage, "pip3": VerbPackage, - "go": VerbPackage, "cargo": VerbPackage, "gem": VerbPackage, - "bundle": VerbPackage, "brew": VerbPackage, "apt": VerbPackage, - "apt-get": VerbPackage, "uv": VerbPackage, "poetry": VerbPackage, -} - -// testCommands are the test runners the test verb class recognises: the -// program, as path.Base names it, and then the plain words that must follow -// it exactly. A runner that is its own program is a list of one. -// -// A fixed list, compared and discarded. Nothing from the line is kept but the -// class; an argument is looked at only to be told equal to one of these. -// Anything this list does not name -- `make -C dir test`, `npx --yes jest`, -// `go test` behind `nice` -- stays what it was before: execute, or package -// for go and cargo. That is an under-claim, and the detections built on this -// class are worth only as much as their refusal to over-claim. -// -// The wrappers on the list are the ones whose exit status is the runner's: -// a project's own gradlew and mvnw, npm's `t` alias, `npx jest` and `npx -// vitest`, `uv run pytest`, `poetry run pytest` and `bundle exec rspec`. So -// are the `timeout N` and `time` prefixes (runnerPrefix), except for -// timeout's own 124, which the report reads as no result. A pipe or a list -// after the runner is still refused (wholeCommand): without pipefail its -// status is the last stage's. -// -// The rule applied: a runner is on the list when it is a known test tool, a -// build tool or launcher given its test command (`go test`, `npm t`, -// `npm run test`, `python -m pytest`, `python -m unittest`), or a listed -// wrapper that passes its runner's exit status through. `make check` is not, -// since check is not make's test command. That rule does not keep lint out: -// go test runs vet first, an npm `pretest` script runs before `npm test`, -// tox's default envlist and a make `test` target can each include lint, and -// a lint failure fixed only in a file named like a test then reads as the -// tests-only pattern. -var testCommands = [][]string{ - {"pytest"}, {"jest"}, {"vitest"}, {"mocha"}, {"rspec"}, {"phpunit"}, - {"ctest"}, {"tox"}, {"nox"}, - - {"go", "test"}, {"cargo", "test"}, {"npm", "test"}, {"npm", "t"}, {"npm", "run", "test"}, - {"yarn", "test"}, {"pnpm", "test"}, {"bun", "test"}, {"dotnet", "test"}, - {"mvn", "test"}, {"mvnw", "test"}, {"gradle", "test"}, {"gradlew", "test"}, {"make", "test"}, - {"python", "-m", "pytest"}, {"python3", "-m", "pytest"}, - {"python", "-m", "unittest"}, {"python3", "-m", "unittest"}, - - {"npx", "jest"}, {"npx", "vitest"}, {"uv", "run", "pytest"}, {"poetry", "run", "pytest"}, - {"bundle", "exec", "rspec"}, -} - -// refusalsOf names the notARun entry a testCommands row is checked against: -// its own program's, or for a wrapper the runner it wraps, since `npx jest -// --listTests` lists as `jest --listTests` does. gradlew and mvnw take -// gradle's and mvn's. -func refusalsOf(c []string) string { - switch c[0] { - case "npx", "uv", "poetry", "bundle": - return c[len(c)-1] - case "gradlew": - return "gradle" - case "mvn", "mvnw": - if len(c) == 1 { - // The build row: a flag that skips the tests still builds. - return "mvn build" - } - return "mvn" - } - return c[0] -} - -// notARun is, per runner by its first word, the arguments with which it does -// something other than run the tests: compile them (`go test -c`, `cargo test -// --no-run`), list them (`-list`, `--collect-only`, `--listTests`), print or -// check what it would do (`make -n`, `make -q`, `--dry-run`), skip them -// (`-DskipTests`, `tox --notest`, `nox --install-only`), set up an -// environment instead (`tox devenv`), watch for changes (`gradle -t`), or -// show its version (`-v` where that is what -v means). Each of those ends ok -// or failed on something no test decided, and as a test run it would -// complete a pattern on that outcome. A word ending in * is a prefix: -// `-list=Foo`, `-elint`, matched whatever follows it. A flag is matched in -// its `flag=value` form too (`-c=true`, `--watch=true`), unless the value is -// false (onList). Maven's skip properties are refused by a rule of their own -// (mavenSkip): `-DskipTests=false` runs the tests. -// -// The lists are the spellings named here, not every spelling a runner -// accepts: a combined short flag (`make -nk`), an option set in a config file -// or an environment variable (other than PYTEST_ADDOPTS, below), or a -// spelling not listed still counts as a test run. -// -// tox and nox are here with their targets, not their listings only: `tox -e -// lint` and `nox -s lint` run a lint session, and a target cannot be told to -// be tests from its name without a guess. Plain `tox` and `nox` run the -// project's default sessions and stay test. -// -// Compared and dropped, like the runner's own words: a match refuses the -// class, and the word is not kept. A word is compared whether quoted or not, -// since the shell passes `"--watch"` as --watch; refusing on it is the -// under-claim. -var notARun = map[string][]string{ - "go": {"-c", "--c", "-n", "--n", "-list*", "--list*"}, - "cargo": {"--no-run", "--list", "-V"}, - "pytest": pytestNotARun, - "python": pytestNotARun, - "python3": pytestNotARun, - "jest": {"--listTests", "--showConfig", "--clearCache", "--init", "-v"}, - "vitest": {"watch", "dev", "list", "bench", "init", "-w", "-v"}, - "mocha": {"-w", "-V", "--dry-run", "--list-reporters", "--list-interfaces"}, - "rspec": {"--dry-run", "--init", "-v"}, - "phpunit": {"--list-*", "--generate-configuration", "--migrate-configuration", "--check-version"}, - "ctest": {"-N", "--show-only*"}, - "dotnet": {"--list-tests", "-t"}, - "mvn": {"-v"}, - "gradle": {"--dry-run", "-m", "-v", "-t", "--continuous"}, - "make": {"-n", "--just-print", "--dry-run", "--recon", "-q", "--question", "-t", "--touch", "-v"}, - "tox": {"-e*", "--env*", "-m", "-f", "-l", "-a", "--listenvs*", "--showconfig", "--help-ini", "--notest", - "--devenv*", "list", "l", "config", "c", "depends", "de", "quickstart", "q", "exec", "e", "devenv", "d"}, - "nox": {"-s*", "--session*", "-e*", "-k*", "--keywords*", "-t*", "--tags*", "-l", "--list*", "--install-only"}, -} - -var pytestNotARun = []string{ - "--collect-only", "--co", "-V", "--fixtures*", "--markers", "--setup-plan", "--setup-only", "--cache-show*", -} - -// notARunAny is the spellings of help, version and watch mode that are -// checked for every runner: -h, -help, --help, --version, --watch and -// --watchAll. Watch mode never ends on a result of its own and records the -// launch's instead. A runner's own spelling of these (`-v`, `-V`, `-w`, `-t`, -// `vitest watch`) is on its notARun entry or is not refused at all; the list -// does not make every runner's help, version or watch mode refused. -var notARunAny = []string{"-h", "-help", "--help", "--version", "--watch", "--watchAll"} - -// refusesRun reports an argument in args that is on runner's notARun list or -// on notARunAny, or, for pytest, a word starting PYTEST_ADDOPTS= earlier on -// the line (before), an assignment or an export's argument: its value is more -// arguments, which may be `--co`, and they are not read, so any value -// refuses. One exported by an earlier call is not on the line and is not -// seen. -func refusesRun(runner string, before, args []token) bool { - // mvn's build row is refused as mvn is, but for the test-skip - // properties: skipping the tests still builds (refusalsOf). - list := notARun[strings.TrimSuffix(runner, " build")] - for _, t := range args { - if onList(t.text, list) || onList(t.text, notARunAny) || runner == "mvn" && mavenSkip(t.text) { - return true - } - } - if onList(runner, []string{"pytest", "python", "python3"}) { - for _, t := range before { - if strings.HasPrefix(t.text, "PYTEST_ADDOPTS=") { - return true - } - } - } - return false -} - -// onList reports word equal to an entry of list, or starting with the part -// of one before its trailing *. A word `flag=value` matches the entry flag -// unless value is a false the flag parsers read as false (isFalse): -// `--watchAll=false` runs the tests once, `--watchAll=true` watches, and -// `-c=true` compiles. A prefix entry takes no =false exception: its flag -// either takes a pattern or name (go -list, tox -e, nox -s/-k/-t, ctest -// --show-only, pytest --cache-show) or is a no-value switch whose parser -// rejects any =value (pytest --fixtures, nox --list, tox --listenvs, phpunit -// --list-*), so the line runs no test either way. -func onList(word string, list []string) bool { - name, value, hasValue := strings.Cut(word, "=") - for _, w := range list { - if p, ok := strings.CutSuffix(w, "*"); ok { - if strings.HasPrefix(word, p) { - return true - } - } else if word == w { - return true - } else if hasValue && strings.HasPrefix(w, "-") && name == w && !isFalse(value) { - return true - } - } - return false -} - -// isFalse reports the false spellings of Go's strconv.ParseBool, for Go's and -// yargs's boolean flags; a yargs flag given another non-true value is still -// refused. Maven's properties follow Java instead (mavenSkip). -func isFalse(v string) bool { - switch v { - case "false", "False", "FALSE", "f", "F", "0": - return true - } - return false -} - -// mavenSkip reports a Maven property that skips the tests: -DskipTests, -// -Dmaven.test.skip or -Dmaven.test.skip.exec, with no `=value` or with a -// value equal to true ignoring case. Maven reads them with Java's -// Boolean.valueOf, where nothing else is true, so `-DskipTests=false`, `=1` -// and `=no` all run the tests. -func mavenSkip(word string) bool { - name, value, hasValue := strings.Cut(word, "=") - switch name { - case "-DskipTests", "-Dmaven.test.skip", "-Dmaven.test.skip.exec": - return !hasValue || strings.EqualFold(value, "true") - } - return false -} - -// runsTests reports whether the command whose program is the token at i, -// named prog, is one of testCommands, and the whole line (wholeCommand), so -// that the line's exit status is the runner's. -// -// The program was found under programToken's rules, so it is certain; each -// word after it must be certain in the same way before it is compared: an -// unquoted word of this same command, and not running on into a process -// substitution the shell reads as more of the same word. A word on the next -// line is the next command's, and wholeCommand refuses any line that has one. -// An operator or an expansion keeps its bytes in the token's text ($, `, ${ ), -// so equality to a list word already refuses those. programToken has refused -// a command whose end it could not find, so the words up to that end are ones -// the lexer vouched for -- and a line the lexer could not finish at all -// (whole) is refused outright: `go test\` ends in a backslash the tokenizer -// dropped and the shell keeps. -// -// Quoted is refused although `go "test"` runs the tests: the rule is "a plain -// word equal to the list", and a quote is where plain stops. And a runner on -// the list is still refused when an argument after its words is on notARun. -func runsTests(toks []token, i int, prog string, whole bool) bool { - if !whole { - return false - } - i, prog, ok := runnerPrefix(toks, i, prog) - if !ok { - return false - } - return runnerOn(toks, i, prog, len(toks), testCommands) && wholeCommand(toks, i) -} - -// runnerOn reports whether the command whose program, past runnerPrefix, is -// the token at i, named prog, and which ends before toks[end], is a row of -// list -- testCommands, or buildCommands -- and is not refused by notARun: -// the comparison runsTests makes, over one command's words rather than the -// whole line's, so that a `-v` given to the grep after a pipe is not read as -// make's. -func runnerOn(toks []token, i int, prog string, end int, list [][]string) bool { -next: - for _, c := range list { - if c[0] != prog { - continue - } - for n, want := range c[1:] { - k := i + 1 + n - if k >= end { - continue next - } - t := toks[k] - if t.quotedAt >= 0 || t.text != want || runsOn(toks, k) { - continue next - } - } - return !refusesRun(refusalsOf(c), toks[:i], toks[i+len(c):end]) - } - return false -} - -// runnerPrefix steps past the prefixes that pass a runner's exit status -// through: `timeout DURATION`, then `time` (`time -p`), each at most once -// and in that order. It returns the index and name of the word after them, -// or i and prog unchanged when there is no prefix; ok is false when a prefix -// is not followed by a word this search can vouch for. -// -// timeout is taken only as `timeout DURATION`: an option before the duration -// (`-k 5`, `-s KILL`, `--preserve-status`) changes what status it exits -// with, and is refused rather than read. Its own 124, when it fires, is the -// timeout's status and not the runner's; the report reads that as no result. -// The recorded program stays timeout or time, the first word. -func runnerPrefix(toks []token, i int, prog string) (int, string, bool) { - // A plain word of this command. The duration is all digits, which - // plainWord refuses in command position, so it is held only to the rest. - plain := func(j int) bool { - if j >= len(toks) { - return false - } - t := toks[j] - return !t.meta && !t.nlBefore && !t.opaque && t.quotedAt < 0 && !runsOn(toks, j) - } - word := func(j int) bool { return plain(j) && plainWord(toks[j].text) } - if prog == "timeout" { - if !plain(i+1) || !isDuration(toks[i+1].text) || !word(i+2) { - return i, prog, false - } - i += 2 - prog = path.Base(toks[i].text) - } - if prog == "time" { - next := i + 1 - if next < len(toks) && toks[next].text == "-p" && word(next) { - next++ - } - if !word(next) { - return i, prog, false - } - i = next - prog = path.Base(toks[i].text) - } - return i, prog, true -} - -// isDuration reports a timeout(1) duration: digits, an optional fraction, and -// an optional unit. -func isDuration(w string) bool { - if n := len(w); n > 0 && strings.IndexByte("smhd", w[n-1]) >= 0 { - w = w[:n-1] - } - whole, frac, dot := strings.Cut(w, ".") - digits := func(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] < '0' || s[i] > '9' { - return false - } - } - return true - } - return whole != "" && digits(whole) && digits(frac) && (!dot || frac != "") -} - -// wholeCommand reports whether the runner whose program is the token at i is -// the whole line: nothing follows its own words, arguments and redirections -// but the end of the line. -// -// The recorded outcome is the line's exit status, and it is the runner's only -// when the runner ran last and in the foreground. `go test ./... 2>&1 | tail` -// ends with tail's status, `|| true` and `; echo done` with true's and echo's, -// `&` with the fork's, and `| grep FAIL` inverts it: grep exits 1 when the -// tests passed. `&&` is refused as well, although a failure there is the -// runner's: its success is whatever ran after it. So any separator, and any -// next line, leaves the class what it was before -- the under-claim. The end -// is found by commandEnd, the scan pastDirectoryChange uses, so a `;` inside -// `$( )` ends nothing; a newline anywhere after the runner refuses, whether -// commandEnd reached it or stopped at it. -func wholeCommand(toks []token, i int) bool { - sep, ok := commandEnd(toks, i, false) - if !ok || sep >= 0 { - return false - } - for _, t := range toks[i+1:] { - if t.nlBefore { - return false - } - } - return true -} - -// backgrounded reports a shell call Claude Code was asked to run in the -// background: its tool_input's run_in_background is the JSON boolean true. -// -// Such a call's PostToolUse fires when the shell is launched, not when the -// command ends, so its recorded outcome is the launch's: ok, before any test -// has run. The digest covers the command line only, so the launch digests -// equal to the same command run in the foreground, and as a test run it would -// pair with a real run as passing. It keeps the class the program gave it. -// The field is read as a boolean and nothing else, like the command field is -// read for its words and dropped. -// -// A command Claude Code moves to the background itself, when it reaches its -// timeout or on Ctrl+B, asked for nothing: its tool_input has no such field, -// so it is a test run here. Its execution record says backgrounded instead -// (read from the response on the post path; on Ctrl+B only if Claude Code -// marks it with backgroundTaskId or backgroundedByUser, which was not -// measured), and the report reads such a run as outcome unobserved, as it -// does a launch declared here. -func backgrounded(raw json.RawMessage) bool { - var s [1]jsonscan.Span - if len(raw) == 0 || jsonscan.Field(raw, jsonscan.ValueSpan(raw), runInBackgroundKey, s[:]) != nil { - return false - } - return s[0].Kind == jsonscan.True -} - -// runInBackgroundKey matches run_in_background in any case, as the struct -// field it was once decoded into did; command and the other fields shape -// reads were decoded from a map, and are matched exactly. -var runInBackgroundKey = jsonscan.NewFoldKeySet("run_in_background") - -// mayWrite reports a shell line whose program is the token at i and which may -// write files whatever class that program gives it: an output redirection to -// a file (not a descriptor duplication such as 2>&1, and not /dev/null); a -// word that writes by itself -- find's -delete, -exec, -execdir, -ok and -// -okdir, xargs, tee, wget, rsync or scp; curl with an output flag (-o, -O, -// --output, --remote-name, or a run of short flags that reaches o or O, with -// or without the file attached); a command or process substitution, whose -// command is not looked into; or a later pipeline or list stage whose program -// is outside the read class, or cannot be named. -// -// The words are compared wherever they stand, quoted or not, and not parsed -// as the program would parse them: `grep -rn xargs .` sets the bit. That is -// the direction to be wrong in. The bit makes a call count as a possible file -// edit (the report's mayEdit), and an edit counted that did not happen stops -// a test-bending pair, while one missed completes a pair over it. -// -// Stages before i are scanned for writer words and redirects, but not classed -// by program: they are the `cd DIR &&` that pastDirectoryChange stepped over, -// and a directory change is told apart by the declarations' directory -// digests, not as a write. -func mayWrite(toks []token, i int) bool { - curl, curlOut := false, false - for j, t := range toks { - if opensSubstitution(toks, j) { - return true - } - if t.meta { - if outputRedirect(toks, j) { - return true - } - continue - } - if t.ticks > 0 || t.opaque && holdsSubstitution(t.text) { - return true - } - switch path.Base(t.text) { - case "xargs", "tee", "wget", "rsync", "scp": - return true - case "curl": - curl = true - } - switch t.text { - case "-delete", "-exec", "-execdir", "-ok", "-okdir": - return true - } - curlOut = curlOut || curlOutputFlag(t.text) - } - if curl && curlOut { - return true - } - for j := i + 1; j < len(toks); j++ { - start := j - switch { - case stageSeparator(toks, j): - start = j + 1 - case toks[j].nlBefore: - default: - continue - } - if start >= len(toks) { - break - } - k, ok := programToken(toks[start:], false) - if !ok || verbForProgram(path.Base(toks[start+k].text)) != VerbRead { - return true - } - } - return false -} - -// outputRedirect reports an output redirection whose operator starts at j and -// whose target is a file: `>`, `>>`, `>|`, `&>`, `<>`, and `>&` onto a word -// that is not a descriptor number or `-`. /dev/null, /dev/stdout, -// /dev/stderr and /dev/tty are not files it writes. -func outputRedirect(toks []token, j int) bool { - t := toks[j] - if t.text != ">" && t.text != ">>" { - return false - } - if j > 0 && toks[j-1].meta && toks[j-1].text == "<" && t.glued { - return true // <>: opened for reading and writing, and created - } - end := operatorEnd(toks, j) - dup := false - for k := j + 1; k <= end; k++ { - dup = dup || toks[k].text == "&" - } - if end+1 >= len(toks) || toks[end+1].meta { - // No target: nothing on the line runs. A process substitution - // writes into a command, which may itself write a file, so it - // counts as a possible write. - return end+1 < len(toks) - } - target := toks[end+1].text - if dup && (target == "-" || isFDPrefix(toks[end+1])) { - return false - } - switch target { - case "/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty": - return false - } - return true -} - -// opensSubstitution reports a token at j that opens a command or process -// substitution: an unquoted `$`, `<` or `>` with a `(` glued after it. A `$` -// with two glued `(` opens arithmetic, `$((n+1))`, which runs nothing, when -// arithmeticAt holds; otherwise it opens a substitution of a subshell. The -// command inside is not classed. -func opensSubstitution(toks []token, j int) bool { - t := toks[j] - if !gluedParen(toks, j+1) { - return false - } - if t.meta { - return t.text == "<" || t.text == ">" - } - return strings.HasSuffix(t.text, "$") && !(gluedParen(toks, j+2) && arithmeticAt(parenText(toks[j+3:]))) -} - -// holdsSubstitution reports text that holds a `$(` opening a command -// substitution: any `$(` but the `$((` of arithmetic. A `$(` nested inside -// arithmetic is followed by a byte other than `(`, so it still counts. -func holdsSubstitution(s string) bool { - for k := 0; k < len(s); k++ { - if !strings.HasPrefix(s[k:], "$(") { - continue - } - if !strings.HasPrefix(s[k+2:], "(") || !arithmeticAt(s[k+3:]) { - return true - } - } - return false -} - -// arithmeticAt reports whether s, the text after a `$((`, is arithmetic: the -// `)` that matches the second `(` is followed by a glued `)`. Bash and zsh -// read a `$((` that does not close as `))`, such as `$((rm x) )`, as a -// command substitution of a subshell. comsub.go's arithEnd is the stricter, -// tokenizer-side form of the same rule. -func arithmeticAt(s string) bool { - depth := 1 - for k := 0; k < len(s); k++ { - switch s[k] { - case '(': - depth++ - case ')': - depth-- - if depth == 0 { - return strings.HasPrefix(s[k+1:], ")") - } - } - } - return false -} - -// parenText rebuilds the line from toks as far as arithmeticAt reads it: an -// operator as written, any other word as one placeholder byte, since an -// unquoted ( or ) is always an operator token, and a blank where two tokens -// were apart. -func parenText(toks []token) string { - var b strings.Builder - for k, t := range toks { - if k > 0 && !t.glued { - b.WriteByte(' ') - } - if t.meta { - b.WriteString(t.text) - } else { - b.WriteByte('w') - } - } - return b.String() -} - -// gluedParen reports a meta `(` at j glued to the token before it. -func gluedParen(toks []token, j int) bool { - return j < len(toks) && toks[j].meta && toks[j].glued && toks[j].text == "(" -} - -// stageSeparator reports a token at j that ends one pipeline or list stage: -// `;`, `&&`, `||`, `|`, or a lone `&` -- not the `&` of `>&`, `&>` or `|&`, -// which belongs to a redirection or a pipe. -func stageSeparator(toks []token, j int) bool { - t := toks[j] - if !t.meta { - return false - } - switch t.text { - case ";", "&&", "||", "|": - return true - case "&": - if j > 0 && t.glued && toks[j-1].meta && (toks[j-1].text == ">" || toks[j-1].text == ">>" || toks[j-1].text == "|") { - return false - } - if next := j + 1; next < len(toks) && toks[next].meta && toks[next].glued && strings.HasPrefix(toks[next].text, ">") { - return false - } - return true - } - return false -} - -// curlOutputFlag reports a curl argument that names an output file: -o, -O, -// their long forms, or a short-option word that reaches o or O. The word is -// walked as curl reads it. A letter, a digit, # or : that takes no argument is -// a flag, and the walk goes on past it; an option that takes an argument ends -// the walk, since the rest of the word is its value (`-XPOST`, `-dfoo=bar`), -// and so does any other character. Whatever follows o is its file, so -// `-o./calc.go`, `-#O` and `-sSLotestdata/x.json` name files. -func curlOutputFlag(w string) bool { - switch { - case w == "--output", w == "--remote-name", w == "--remote-name-all", w == "--output-dir", - strings.HasPrefix(w, "--output="), strings.HasPrefix(w, "--output-dir="): - return true - case len(w) > 1 && w[0] == '-' && w[1] != '-': - for _, c := range w[1:] { - switch { - case c == 'o' || c == 'O': - return true - case strings.ContainsRune(curlArgumentOptions, c): - return false - case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9', c == '#', c == ':': - default: - return false - } - } - } - return false -} - -// curlArgumentOptions are curl's short options that take an argument, per -// `curl --help all`. -const curlArgumentOptions = "AbcCdDeEFhHKmPQrtTuUwxXyYz" - -func verbForProgram(prog string) string { - if v, ok := programVerb[prog]; ok { - return v - } - return VerbExecute -} diff --git a/internal/shape/verb.go b/internal/shape/verb.go new file mode 100644 index 00000000..78fe5497 --- /dev/null +++ b/internal/shape/verb.go @@ -0,0 +1,689 @@ +package shape + +import ( + "encoding/json" + "path" + "slices" + "strings" + + "github.com/altrace-dev-role/rashomon/internal/jsonscan" +) + +// ToolVerb is the verb class a tool's name alone gives, before any command +// line is read: what Derive starts from. A shell tool is execute here +// whatever its command runs, so this is the answer for the tool, never for +// the call. +func ToolVerb(name string) string { return verbForTool(name) } + +// readTools are the tools classed read by name alone. It is the one list: +// report's lookups (IsLookup) and the words the report and spend name them +// with are both read from it, through ReadTools. +var readTools = []string{"Read", "Glob", "Grep", "NotebookRead"} + +// ReadTools returns the tools classed read by name alone, in a fresh slice a +// caller may keep or change. +func ReadTools() []string { return slices.Clone(readTools) } + +func verbForTool(name string) string { + if strings.HasPrefix(name, "mcp__") { + return VerbMCP + } + if slices.Contains(readTools, name) { + return VerbRead + } + switch name { + case "Write", "Edit", "MultiEdit", "NotebookEdit": + return VerbWrite + case "WebFetch", "WebSearch": + return VerbNetwork + case "Bash", "BashOutput", "KillShell": + return VerbExecute + case "Task", "Agent": + return VerbAgent + default: + return VerbUnknown + } +} + +// programVerb is a coarse classification and is documented as such. It exists to +// make a declaration readable at a glance, not to be a security boundary: an +// unrecognized program is "execute", which is the truthful answer for anything +// being run. +var programVerb = map[string]string{ + "cat": VerbRead, "less": VerbRead, "more": VerbRead, "head": VerbRead, + "tail": VerbRead, "ls": VerbRead, "find": VerbRead, "grep": VerbRead, + "rg": VerbRead, "wc": VerbRead, "stat": VerbRead, "file": VerbRead, + "tree": VerbRead, "diff": VerbRead, "realpath": VerbRead, "pwd": VerbRead, + + "rm": VerbWrite, "mv": VerbWrite, "cp": VerbWrite, "mkdir": VerbWrite, + "rmdir": VerbWrite, "touch": VerbWrite, "tee": VerbWrite, "ln": VerbWrite, + "chmod": VerbWrite, "chown": VerbWrite, "truncate": VerbWrite, + + "curl": VerbNetwork, "wget": VerbNetwork, "ssh": VerbNetwork, + "scp": VerbNetwork, "rsync": VerbNetwork, "nc": VerbNetwork, + "dig": VerbNetwork, "host": VerbNetwork, "ping": VerbNetwork, + + "git": VerbVCS, "gh": VerbVCS, "hg": VerbVCS, "svn": VerbVCS, + + "npm": VerbPackage, "npx": VerbPackage, "yarn": VerbPackage, + "pnpm": VerbPackage, "pip": VerbPackage, "pip3": VerbPackage, + "go": VerbPackage, "cargo": VerbPackage, "gem": VerbPackage, + "bundle": VerbPackage, "brew": VerbPackage, "apt": VerbPackage, + "apt-get": VerbPackage, "uv": VerbPackage, "poetry": VerbPackage, +} + +// testCommands are the test runners the test verb class recognises: the +// program, as path.Base names it, and then the plain words that must follow +// it exactly. A runner that is its own program is a list of one. +// +// A fixed list, compared and discarded. Nothing from the line is kept but the +// class; an argument is looked at only to be told equal to one of these. +// Anything this list does not name -- `make -C dir test`, `npx --yes jest`, +// `go test` behind `nice` -- stays what it was before: execute, or package +// for go and cargo. That is an under-claim, and the detections built on this +// class are worth only as much as their refusal to over-claim. +// +// The wrappers on the list are the ones whose exit status is the runner's: +// a project's own gradlew and mvnw, npm's `t` alias, `npx jest` and `npx +// vitest`, `uv run pytest`, `poetry run pytest` and `bundle exec rspec`. So +// are the `timeout N` and `time` prefixes (runnerPrefix), except for +// timeout's own 124, which the report reads as no result. A pipe or a list +// after the runner is still refused (wholeCommand): without pipefail its +// status is the last stage's. +// +// The rule applied: a runner is on the list when it is a known test tool, a +// build tool or launcher given its test command (`go test`, `npm t`, +// `npm run test`, `python -m pytest`, `python -m unittest`), or a listed +// wrapper that passes its runner's exit status through. `make check` is not, +// since check is not make's test command. That rule does not keep lint out: +// go test runs vet first, an npm `pretest` script runs before `npm test`, +// tox's default envlist and a make `test` target can each include lint, and +// a lint failure fixed only in a file named like a test then reads as the +// tests-only pattern. +var testCommands = [][]string{ + {"pytest"}, {"jest"}, {"vitest"}, {"mocha"}, {"rspec"}, {"phpunit"}, + {"ctest"}, {"tox"}, {"nox"}, + + {"go", "test"}, {"cargo", "test"}, {"npm", "test"}, {"npm", "t"}, {"npm", "run", "test"}, + {"yarn", "test"}, {"pnpm", "test"}, {"bun", "test"}, {"dotnet", "test"}, + {"mvn", "test"}, {"mvnw", "test"}, {"gradle", "test"}, {"gradlew", "test"}, {"make", "test"}, + {"python", "-m", "pytest"}, {"python3", "-m", "pytest"}, + {"python", "-m", "unittest"}, {"python3", "-m", "unittest"}, + + {"npx", "jest"}, {"npx", "vitest"}, {"uv", "run", "pytest"}, {"poetry", "run", "pytest"}, + {"bundle", "exec", "rspec"}, +} + +// refusalsOf names the notARun entry a testCommands row is checked against: +// its own program's, or for a wrapper the runner it wraps, since `npx jest +// --listTests` lists as `jest --listTests` does. gradlew and mvnw take +// gradle's and mvn's. +func refusalsOf(c []string) string { + switch c[0] { + case "npx", "uv", "poetry", "bundle": + return c[len(c)-1] + case "gradlew": + return "gradle" + case "mvn", "mvnw": + if len(c) == 1 { + // The build row: a flag that skips the tests still builds. + return "mvn build" + } + return "mvn" + } + return c[0] +} + +// notARun is, per runner by its first word, the arguments with which it does +// something other than run the tests: compile them (`go test -c`, `cargo test +// --no-run`), list them (`-list`, `--collect-only`, `--listTests`), print or +// check what it would do (`make -n`, `make -q`, `--dry-run`), skip them +// (`-DskipTests`, `tox --notest`, `nox --install-only`), set up an +// environment instead (`tox devenv`), watch for changes (`gradle -t`), or +// show its version (`-v` where that is what -v means). Each of those ends ok +// or failed on something no test decided, and as a test run it would +// complete a pattern on that outcome. A word ending in * is a prefix: +// `-list=Foo`, `-elint`, matched whatever follows it. A flag is matched in +// its `flag=value` form too (`-c=true`, `--watch=true`), unless the value is +// false (onList). Maven's skip properties are refused by a rule of their own +// (mavenSkip): `-DskipTests=false` runs the tests. +// +// The lists are the spellings named here, not every spelling a runner +// accepts: a combined short flag (`make -nk`), an option set in a config file +// or an environment variable (other than PYTEST_ADDOPTS, below), or a +// spelling not listed still counts as a test run. +// +// tox and nox are here with their targets, not their listings only: `tox -e +// lint` and `nox -s lint` run a lint session, and a target cannot be told to +// be tests from its name without a guess. Plain `tox` and `nox` run the +// project's default sessions and stay test. +// +// Compared and dropped, like the runner's own words: a match refuses the +// class, and the word is not kept. A word is compared whether quoted or not, +// since the shell passes `"--watch"` as --watch; refusing on it is the +// under-claim. +var notARun = map[string][]string{ + "go": {"-c", "--c", "-n", "--n", "-list*", "--list*"}, + "cargo": {"--no-run", "--list", "-V"}, + "pytest": pytestNotARun, + "python": pytestNotARun, + "python3": pytestNotARun, + "jest": {"--listTests", "--showConfig", "--clearCache", "--init", "-v"}, + "vitest": {"watch", "dev", "list", "bench", "init", "-w", "-v"}, + "mocha": {"-w", "-V", "--dry-run", "--list-reporters", "--list-interfaces"}, + "rspec": {"--dry-run", "--init", "-v"}, + "phpunit": {"--list-*", "--generate-configuration", "--migrate-configuration", "--check-version"}, + "ctest": {"-N", "--show-only*"}, + "dotnet": {"--list-tests", "-t"}, + "mvn": {"-v"}, + "gradle": {"--dry-run", "-m", "-v", "-t", "--continuous"}, + "make": {"-n", "--just-print", "--dry-run", "--recon", "-q", "--question", "-t", "--touch", "-v"}, + "tox": {"-e*", "--env*", "-m", "-f", "-l", "-a", "--listenvs*", "--showconfig", "--help-ini", "--notest", + "--devenv*", "list", "l", "config", "c", "depends", "de", "quickstart", "q", "exec", "e", "devenv", "d"}, + "nox": {"-s*", "--session*", "-e*", "-k*", "--keywords*", "-t*", "--tags*", "-l", "--list*", "--install-only"}, +} + +var pytestNotARun = []string{ + "--collect-only", "--co", "-V", "--fixtures*", "--markers", "--setup-plan", "--setup-only", "--cache-show*", +} + +// notARunAny is the spellings of help, version and watch mode that are +// checked for every runner: -h, -help, --help, --version, --watch and +// --watchAll. Watch mode never ends on a result of its own and records the +// launch's instead. A runner's own spelling of these (`-v`, `-V`, `-w`, `-t`, +// `vitest watch`) is on its notARun entry or is not refused at all; the list +// does not make every runner's help, version or watch mode refused. +var notARunAny = []string{"-h", "-help", "--help", "--version", "--watch", "--watchAll"} + +// refusesRun reports an argument in args that is on runner's notARun list or +// on notARunAny, or, for pytest, a word starting PYTEST_ADDOPTS= earlier on +// the line (before), an assignment or an export's argument: its value is more +// arguments, which may be `--co`, and they are not read, so any value +// refuses. One exported by an earlier call is not on the line and is not +// seen. +func refusesRun(runner string, before, args []token) bool { + // mvn's build row is refused as mvn is, but for the test-skip + // properties: skipping the tests still builds (refusalsOf). + list := notARun[strings.TrimSuffix(runner, " build")] + for _, t := range args { + if onList(t.text, list) || onList(t.text, notARunAny) || runner == "mvn" && mavenSkip(t.text) { + return true + } + } + if onList(runner, []string{"pytest", "python", "python3"}) { + for _, t := range before { + if strings.HasPrefix(t.text, "PYTEST_ADDOPTS=") { + return true + } + } + } + return false +} + +// onList reports word equal to an entry of list, or starting with the part +// of one before its trailing *. A word `flag=value` matches the entry flag +// unless value is a false the flag parsers read as false (isFalse): +// `--watchAll=false` runs the tests once, `--watchAll=true` watches, and +// `-c=true` compiles. A prefix entry takes no =false exception: its flag +// either takes a pattern or name (go -list, tox -e, nox -s/-k/-t, ctest +// --show-only, pytest --cache-show) or is a no-value switch whose parser +// rejects any =value (pytest --fixtures, nox --list, tox --listenvs, phpunit +// --list-*), so the line runs no test either way. +func onList(word string, list []string) bool { + name, value, hasValue := strings.Cut(word, "=") + for _, w := range list { + if p, ok := strings.CutSuffix(w, "*"); ok { + if strings.HasPrefix(word, p) { + return true + } + } else if word == w { + return true + } else if hasValue && strings.HasPrefix(w, "-") && name == w && !isFalse(value) { + return true + } + } + return false +} + +// isFalse reports the false spellings of Go's strconv.ParseBool, for Go's and +// yargs's boolean flags; a yargs flag given another non-true value is still +// refused. Maven's properties follow Java instead (mavenSkip). +func isFalse(v string) bool { + switch v { + case "false", "False", "FALSE", "f", "F", "0": + return true + } + return false +} + +// mavenSkip reports a Maven property that skips the tests: -DskipTests, +// -Dmaven.test.skip or -Dmaven.test.skip.exec, with no `=value` or with a +// value equal to true ignoring case. Maven reads them with Java's +// Boolean.valueOf, where nothing else is true, so `-DskipTests=false`, `=1` +// and `=no` all run the tests. +func mavenSkip(word string) bool { + name, value, hasValue := strings.Cut(word, "=") + switch name { + case "-DskipTests", "-Dmaven.test.skip", "-Dmaven.test.skip.exec": + return !hasValue || strings.EqualFold(value, "true") + } + return false +} + +// runsTests reports whether the command whose program is the token at i, +// named prog, is one of testCommands, and the whole line (wholeCommand), so +// that the line's exit status is the runner's. +// +// The program was found under programToken's rules, so it is certain; each +// word after it must be certain in the same way before it is compared: an +// unquoted word of this same command, and not running on into a process +// substitution the shell reads as more of the same word. A word on the next +// line is the next command's, and wholeCommand refuses any line that has one. +// An operator or an expansion keeps its bytes in the token's text ($, `, ${ ), +// so equality to a list word already refuses those. programToken has refused +// a command whose end it could not find, so the words up to that end are ones +// the lexer vouched for -- and a line the lexer could not finish at all +// (whole) is refused outright: `go test\` ends in a backslash the tokenizer +// dropped and the shell keeps. +// +// Quoted is refused although `go "test"` runs the tests: the rule is "a plain +// word equal to the list", and a quote is where plain stops. And a runner on +// the list is still refused when an argument after its words is on notARun. +func runsTests(toks []token, i int, prog string, whole bool) bool { + if !whole { + return false + } + i, prog, ok := runnerPrefix(toks, i, prog) + if !ok { + return false + } + return runnerOn(toks, i, prog, len(toks), testCommands) && wholeCommand(toks, i) +} + +// runnerOn reports whether the command whose program, past runnerPrefix, is +// the token at i, named prog, and which ends before toks[end], is a row of +// list -- testCommands, or buildCommands -- and is not refused by notARun: +// the comparison runsTests makes, over one command's words rather than the +// whole line's, so that a `-v` given to the grep after a pipe is not read as +// make's. +func runnerOn(toks []token, i int, prog string, end int, list [][]string) bool { +next: + for _, c := range list { + if c[0] != prog { + continue + } + for n, want := range c[1:] { + k := i + 1 + n + if k >= end { + continue next + } + t := toks[k] + if t.quotedAt >= 0 || t.text != want || runsOn(toks, k) { + continue next + } + } + return !refusesRun(refusalsOf(c), toks[:i], toks[i+len(c):end]) + } + return false +} + +// runnerPrefix steps past the prefixes that pass a runner's exit status +// through: `timeout DURATION`, then `time` (`time -p`), each at most once +// and in that order. It returns the index and name of the word after them, +// or i and prog unchanged when there is no prefix; ok is false when a prefix +// is not followed by a word this search can vouch for. +// +// timeout is taken only as `timeout DURATION`: an option before the duration +// (`-k 5`, `-s KILL`, `--preserve-status`) changes what status it exits +// with, and is refused rather than read. Its own 124, when it fires, is the +// timeout's status and not the runner's; the report reads that as no result. +// The recorded program stays timeout or time, the first word. +func runnerPrefix(toks []token, i int, prog string) (int, string, bool) { + // A plain word of this command. The duration is all digits, which + // plainWord refuses in command position, so it is held only to the rest. + plain := func(j int) bool { + if j >= len(toks) { + return false + } + t := toks[j] + return !t.meta && !t.nlBefore && !t.opaque && t.quotedAt < 0 && !runsOn(toks, j) + } + word := func(j int) bool { return plain(j) && plainWord(toks[j].text) } + if prog == "timeout" { + if !plain(i+1) || !isDuration(toks[i+1].text) || !word(i+2) { + return i, prog, false + } + i += 2 + prog = path.Base(toks[i].text) + } + if prog == "time" { + next := i + 1 + if next < len(toks) && toks[next].text == "-p" && word(next) { + next++ + } + if !word(next) { + return i, prog, false + } + i = next + prog = path.Base(toks[i].text) + } + return i, prog, true +} + +// isDuration reports a timeout(1) duration: digits, an optional fraction, and +// an optional unit. +func isDuration(w string) bool { + if n := len(w); n > 0 && strings.IndexByte("smhd", w[n-1]) >= 0 { + w = w[:n-1] + } + whole, frac, dot := strings.Cut(w, ".") + digits := func(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true + } + return whole != "" && digits(whole) && digits(frac) && (!dot || frac != "") +} + +// wholeCommand reports whether the runner whose program is the token at i is +// the whole line: nothing follows its own words, arguments and redirections +// but the end of the line. +// +// The recorded outcome is the line's exit status, and it is the runner's only +// when the runner ran last and in the foreground. `go test ./... 2>&1 | tail` +// ends with tail's status, `|| true` and `; echo done` with true's and echo's, +// `&` with the fork's, and `| grep FAIL` inverts it: grep exits 1 when the +// tests passed. `&&` is refused as well, although a failure there is the +// runner's: its success is whatever ran after it. So any separator, and any +// next line, leaves the class what it was before -- the under-claim. The end +// is found by commandEnd, the scan pastDirectoryChange uses, so a `;` inside +// `$( )` ends nothing; a newline anywhere after the runner refuses, whether +// commandEnd reached it or stopped at it. +func wholeCommand(toks []token, i int) bool { + sep, ok := commandEnd(toks, i, false) + if !ok || sep >= 0 { + return false + } + for _, t := range toks[i+1:] { + if t.nlBefore { + return false + } + } + return true +} + +// backgrounded reports a shell call Claude Code was asked to run in the +// background: its tool_input's run_in_background is the JSON boolean true. +// +// Such a call's PostToolUse fires when the shell is launched, not when the +// command ends, so its recorded outcome is the launch's: ok, before any test +// has run. The digest covers the command line only, so the launch digests +// equal to the same command run in the foreground, and as a test run it would +// pair with a real run as passing. It keeps the class the program gave it. +// The field is read as a boolean and nothing else, like the command field is +// read for its words and dropped. +// +// A command Claude Code moves to the background itself, when it reaches its +// timeout or on Ctrl+B, asked for nothing: its tool_input has no such field, +// so it is a test run here. Its execution record says backgrounded instead +// (read from the response on the post path; on Ctrl+B only if Claude Code +// marks it with backgroundTaskId or backgroundedByUser, which was not +// measured), and the report reads such a run as outcome unobserved, as it +// does a launch declared here. +func backgrounded(raw json.RawMessage) bool { + var s [1]jsonscan.Span + if len(raw) == 0 || jsonscan.Field(raw, jsonscan.ValueSpan(raw), runInBackgroundKey, s[:]) != nil { + return false + } + return s[0].Kind == jsonscan.True +} + +// runInBackgroundKey matches run_in_background in any case, as the struct +// field it was once decoded into did; command and the other fields shape +// reads were decoded from a map, and are matched exactly. +var runInBackgroundKey = jsonscan.NewFoldKeySet("run_in_background") + +// mayWrite reports a shell line whose program is the token at i and which may +// write files whatever class that program gives it: an output redirection to +// a file (not a descriptor duplication such as 2>&1, and not /dev/null); a +// word that writes by itself -- find's -delete, -exec, -execdir, -ok and +// -okdir, xargs, tee, wget, rsync or scp; curl with an output flag (-o, -O, +// --output, --remote-name, or a run of short flags that reaches o or O, with +// or without the file attached); a command or process substitution, whose +// command is not looked into; or a later pipeline or list stage whose program +// is outside the read class, or cannot be named. +// +// The words are compared wherever they stand, quoted or not, and not parsed +// as the program would parse them: `grep -rn xargs .` sets the bit. That is +// the direction to be wrong in. The bit makes a call count as a possible file +// edit (the report's mayEdit), and an edit counted that did not happen stops +// a test-bending pair, while one missed completes a pair over it. +// +// Stages before i are scanned for writer words and redirects, but not classed +// by program: they are the `cd DIR &&` that pastDirectoryChange stepped over, +// and a directory change is told apart by the declarations' directory +// digests, not as a write. +func mayWrite(toks []token, i int) bool { + curl, curlOut := false, false + for j, t := range toks { + if opensSubstitution(toks, j) { + return true + } + if t.meta { + if outputRedirect(toks, j) { + return true + } + continue + } + if t.ticks > 0 || t.opaque && holdsSubstitution(t.text) { + return true + } + switch path.Base(t.text) { + case "xargs", "tee", "wget", "rsync", "scp": + return true + case "curl": + curl = true + } + switch t.text { + case "-delete", "-exec", "-execdir", "-ok", "-okdir": + return true + } + curlOut = curlOut || curlOutputFlag(t.text) + } + if curl && curlOut { + return true + } + for j := i + 1; j < len(toks); j++ { + start := j + switch { + case stageSeparator(toks, j): + start = j + 1 + case toks[j].nlBefore: + default: + continue + } + if start >= len(toks) { + break + } + k, ok := programToken(toks[start:], false) + if !ok || verbForProgram(path.Base(toks[start+k].text)) != VerbRead { + return true + } + } + return false +} + +// outputRedirect reports an output redirection whose operator starts at j and +// whose target is a file: `>`, `>>`, `>|`, `&>`, `<>`, and `>&` onto a word +// that is not a descriptor number or `-`. /dev/null, /dev/stdout, +// /dev/stderr and /dev/tty are not files it writes. +func outputRedirect(toks []token, j int) bool { + t := toks[j] + if t.text != ">" && t.text != ">>" { + return false + } + if j > 0 && toks[j-1].meta && toks[j-1].text == "<" && t.glued { + return true // <>: opened for reading and writing, and created + } + end := operatorEnd(toks, j) + dup := false + for k := j + 1; k <= end; k++ { + dup = dup || toks[k].text == "&" + } + if end+1 >= len(toks) || toks[end+1].meta { + // No target: nothing on the line runs. A process substitution + // writes into a command, which may itself write a file, so it + // counts as a possible write. + return end+1 < len(toks) + } + target := toks[end+1].text + if dup && (target == "-" || isFDPrefix(toks[end+1])) { + return false + } + switch target { + case "/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty": + return false + } + return true +} + +// opensSubstitution reports a token at j that opens a command or process +// substitution: an unquoted `$`, `<` or `>` with a `(` glued after it. A `$` +// with two glued `(` opens arithmetic, `$((n+1))`, which runs nothing, when +// arithmeticAt holds; otherwise it opens a substitution of a subshell. The +// command inside is not classed. +func opensSubstitution(toks []token, j int) bool { + t := toks[j] + if !gluedParen(toks, j+1) { + return false + } + if t.meta { + return t.text == "<" || t.text == ">" + } + return strings.HasSuffix(t.text, "$") && !(gluedParen(toks, j+2) && arithmeticAt(parenText(toks[j+3:]))) +} + +// holdsSubstitution reports text that holds a `$(` opening a command +// substitution: any `$(` but the `$((` of arithmetic. A `$(` nested inside +// arithmetic is followed by a byte other than `(`, so it still counts. +func holdsSubstitution(s string) bool { + for k := 0; k < len(s); k++ { + if !strings.HasPrefix(s[k:], "$(") { + continue + } + if !strings.HasPrefix(s[k+2:], "(") || !arithmeticAt(s[k+3:]) { + return true + } + } + return false +} + +// arithmeticAt reports whether s, the text after a `$((`, is arithmetic: the +// `)` that matches the second `(` is followed by a glued `)`. Bash and zsh +// read a `$((` that does not close as `))`, such as `$((rm x) )`, as a +// command substitution of a subshell. comsub.go's arithEnd is the stricter, +// tokenizer-side form of the same rule. +func arithmeticAt(s string) bool { + depth := 1 + for k := 0; k < len(s); k++ { + switch s[k] { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return strings.HasPrefix(s[k+1:], ")") + } + } + } + return false +} + +// parenText rebuilds the line from toks as far as arithmeticAt reads it: an +// operator as written, any other word as one placeholder byte, since an +// unquoted ( or ) is always an operator token, and a blank where two tokens +// were apart. +func parenText(toks []token) string { + var b strings.Builder + for k, t := range toks { + if k > 0 && !t.glued { + b.WriteByte(' ') + } + if t.meta { + b.WriteString(t.text) + } else { + b.WriteByte('w') + } + } + return b.String() +} + +// gluedParen reports a meta `(` at j glued to the token before it. +func gluedParen(toks []token, j int) bool { + return j < len(toks) && toks[j].meta && toks[j].glued && toks[j].text == "(" +} + +// stageSeparator reports a token at j that ends one pipeline or list stage: +// `;`, `&&`, `||`, `|`, or a lone `&` -- not the `&` of `>&`, `&>` or `|&`, +// which belongs to a redirection or a pipe. +func stageSeparator(toks []token, j int) bool { + t := toks[j] + if !t.meta { + return false + } + switch t.text { + case ";", "&&", "||", "|": + return true + case "&": + if j > 0 && t.glued && toks[j-1].meta && (toks[j-1].text == ">" || toks[j-1].text == ">>" || toks[j-1].text == "|") { + return false + } + if next := j + 1; next < len(toks) && toks[next].meta && toks[next].glued && strings.HasPrefix(toks[next].text, ">") { + return false + } + return true + } + return false +} + +// curlOutputFlag reports a curl argument that names an output file: -o, -O, +// their long forms, or a short-option word that reaches o or O. The word is +// walked as curl reads it. A letter, a digit, # or : that takes no argument is +// a flag, and the walk goes on past it; an option that takes an argument ends +// the walk, since the rest of the word is its value (`-XPOST`, `-dfoo=bar`), +// and so does any other character. Whatever follows o is its file, so +// `-o./calc.go`, `-#O` and `-sSLotestdata/x.json` name files. +func curlOutputFlag(w string) bool { + switch { + case w == "--output", w == "--remote-name", w == "--remote-name-all", w == "--output-dir", + strings.HasPrefix(w, "--output="), strings.HasPrefix(w, "--output-dir="): + return true + case len(w) > 1 && w[0] == '-' && w[1] != '-': + for _, c := range w[1:] { + switch { + case c == 'o' || c == 'O': + return true + case strings.ContainsRune(curlArgumentOptions, c): + return false + case 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9', c == '#', c == ':': + default: + return false + } + } + } + return false +} + +// curlArgumentOptions are curl's short options that take an argument, per +// `curl --help all`. +const curlArgumentOptions = "AbcCdDeEFhHKmPQrtTuUwxXyYz" + +func verbForProgram(prog string) string { + if v, ok := programVerb[prog]; ok { + return v + } + return VerbExecute +} diff --git a/internal/shape/words.go b/internal/shape/words.go new file mode 100644 index 00000000..dbc0f786 --- /dev/null +++ b/internal/shape/words.go @@ -0,0 +1,226 @@ +package shape + +import "strings" + +// isComment reports whether a word begins a comment: an unquoted `#` at its +// start. +func isComment(t token) bool { + return strings.HasPrefix(t.text, "#") && t.quotedAt != 0 +} + +// isFDPrefix reports whether an unquoted word could be an fd number (2 in 2>) +// or an fd variable ({fd} in {fd}>). +func isFDPrefix(t token) bool { + if t.quotedAt >= 0 || t.text == "" { + return false + } + if strings.HasPrefix(t.text, "{") && strings.HasSuffix(t.text, "}") { + return true + } + for j := 0; j < len(t.text); j++ { + if t.text[j] < '0' || t.text[j] > '9' { + return false + } + } + return true +} + +// redirectEnd returns the index of the last token of the redirection whose +// operator is at i -- the operator, the rest of an operator the tokenizer +// emitted as more than one token, and the word it redirects to -- or false +// when where the redirection ends cannot be told, or when it is a syntax error +// (noTarget), which leaves nothing on the line to run. +// +// The target is taken only if it is a word, and only if that word is certainly +// the whole target: not an expansion the tokenizer split, not the start of a +// glob or array (a paren glued to it), and not zsh's `!` -- `>! f` is a +// clobber into f in zsh and a redirect into a file named ! in bash, and the +// two disagree about which word after it is the command. +func redirectEnd(toks []token, i int) (int, bool) { + i = operatorEnd(toks, i) + if noTarget(toks, i) { + return 0, false + } + if toks[i+1].meta { + // A paren where the target belongs -- a zsh glob such as (a|b) or + // (x).csv -- or a process substitution, <( ) or >( ), whose operator + // is here and its paren next. Either way the words inside it are not + // in command position for this line. + return 0, false + } + target := toks[i+1] + if target.opaque || target.text == "!" && target.quotedAt < 0 { + return 0, false + } + i++ + if i+1 < len(toks) && toks[i+1].meta && toks[i+1].text == "(" { + return 0, false + } + return i, true +} + +// noTarget reports a redirection whose operator ends at i with no word where +// its target belongs, which both shells reject, so that nothing on the line +// runs. Read past, the word after it was taken for the program, and in +// `> ; /home/alice/secret.csv x` that word is a path. There is no target when: +// +// - the line ends there; +// - the next word is on the next line, where it begins the next command; +// - a comment begins there (`ls > #x`), which runs to the end of the line; +// - an operator comes first -- a separator, `> ;` or `< |`, or another +// redirection, `> > f`, which owns the word after it -- unless it is a +// paren, a glob or process substitution that the callers refuse or skip, +// or a process substitution's own `<` or `>`, as in `cat < <(ls)`. +func noTarget(toks []token, i int) bool { + if i+1 >= len(toks) { + return true + } + next := toks[i+1] + switch { + case next.nlBefore: + return true + case isComment(next): + return true + case !next.meta, next.text == "(": + return false + } + return !procSub(toks, i+1) +} + +// procSub reports a process substitution beginning at j: a `<` or `>` with a +// paren glued to it, which bash and zsh read as a word. +func procSub(toks []token, j int) bool { + op := toks[j].text + if op != "<" && op != ">" || j+1 >= len(toks) { + return false + } + next := toks[j+1] + return next.meta && next.glued && next.text == "(" +} + +// operatorEnd returns the index of the last piece of the redirection operator +// at i. +// +// The tokenizer doubles a metacharacter only when the next byte is the same +// byte, so `>&`, `>|`, `<>`, `<&` and a here-string's `<<<` arrive as two meta +// tokens, and zsh's `>>|`, `>>&`, `>&|` and `>>&|` as two or three. A piece +// counts only glued to the one before it: `>|` is one operator and `> |` is +// two, and the text of the tokens does not say which. With a blank or a +// newline before it, a piece is the next operator, standing where this one's +// target belongs, and noTarget refuses it: `> |` and `>\n&` are a redirect +// with no target and then a separator, and `<< <&` a here-document with no +// delimiter. Joined, the next word read as the target and an argument as the +// program. +func operatorEnd(toks []token, i int) int { + op := toks[i].text + // At most one further piece, except after > and >>, which zsh extends + // by two (>&| and >>&|). A second `<` after `<<` is the next + // redirection, not more of this one. + pieces := 1 + if op == ">" || op == ">>" { + pieces = 2 + } + for n := 0; n < pieces && i+1 < len(toks) && toks[i+1].meta && toks[i+1].glued && continuesRedirect(op, toks[i+1].text); n++ { + i++ + } + return i +} + +// continuesRedirect reports whether next is a further piece of the +// redirection operator op, which the tokenizer emitted as separate tokens. +func continuesRedirect(op, next string) bool { + switch op { + case ">", ">>": + return next == "&" || next == "|" + case "<": + return next == ">" || next == "&" + case "<<": + return next == "<" + } + return false +} + +// isRedirect reports whether a metacharacter token redirects, and so is +// followed by a filename rather than by a command. +func isRedirect(tok string) bool { + switch tok { + case ">", "<", ">>", "<<": + return true + } + return false +} + +func isAssignment(tok string) bool { + i := strings.IndexByte(tok, '=') + if i <= 0 { + return false + } + for j := 0; j < i; j++ { + c := tok[j] + switch { + case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c == '_': + case c >= '0' && c <= '9' && j > 0: + default: + return false + } + } + return true +} + +// isShellAssignment reports whether a word is a variable assignment the shell +// would perform: NAME=value, NAME+=value, or NAME[subscript]=value, with the +// name, the `[` and the `=` unquoted -- quoted, 'FOO=bar' is a command by that +// name. A subscript may itself hold `=` (arr[i==0]=v), so the `=` looked for is +// the one after the subscript's matching `]`. +// +// It is wider than isAssignment, which decides argc and is left as it was so +// that a count stays the count it has always been. Program detection cannot +// afford that narrowness: an assignment form it did not recognise used to be +// returned as the program, value and all (PATH+=:/home/u/dir named "dir"). +func isShellAssignment(t token) bool { + s := t.text + j := 0 + for j < len(s) { + c := s[j] + if c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c == '_' || c >= '0' && c <= '9' && j > 0 { + j++ + continue + } + break + } + if j == 0 || j >= len(s) { + return false + } + if s[j] == '[' { + depth := 0 + k := j + for ; k < len(s); k++ { + if s[k] == '[' { + depth++ + } else if s[k] == ']' { + depth-- + if depth == 0 { + break + } + } + } + if k >= len(s) { + return false + } + j = k + 1 + } + eq := j + if eq < len(s) && s[eq] == '+' { + eq++ + } + if eq >= len(s) || s[eq] != '=' { + return false + } + // Nothing before the `=` may be quoted. Bash decides an assignment on + // the raw word and needs a literal `]` then `=`; a quoted or escaped + // byte anywhere before it -- arr[0]"="x, arr[0\]=x -- makes the word a + // command instead, and skipping it would read its argument as the + // program. A quoted subscript that bash would still accept (arr["k"]=v) + // is refused too: null is the cautious answer there, not a leak. + return t.quotedAt < 0 || t.quotedAt > eq +} diff --git a/test/mutation/mutants/FF_shape.py b/test/mutation/mutants/FF_shape.py index 8dbf5f73..0af01399 100644 --- a/test/mutation/mutants/FF_shape.py +++ b/test/mutation/mutants/FF_shape.py @@ -13,11 +13,11 @@ m("FF the program is cd for every cd-and-command line", "internal/shape/shape.go", "\t\tif i, ok = pastDirectoryChange(pshaped, i, uncertain); ok {", "\t\tif i, ok = i, true; ok {", "TestProgramLooksPastADirectoryChange|TestProgramIsAProgram") -m("FF the separator after cd is found without the command-end scan", "internal/shape/shape.go", +m("FF the separator after cd is found without the command-end scan", "internal/shape/program.go", "\t\tsep, ok := commandEnd(toks, i, uncertain)\n\t\tif !ok {\n\t\t\treturn 0, false\n\t\t}\n", "\t\tsep := -1\n\t\tfor j := i + 1; j < len(toks); j++ {\n\t\t\tif toks[j].meta {\n\t\t\t\tsep = j\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n", "TestProgramLooksPastADirectoryChange") -m("FF a separator in a comment after cd is followed", "internal/shape/shape.go", +m("FF a separator in a comment after cd is followed", "internal/shape/program.go", "\t\t\tif isComment(toks[j]) {\n\t\t\t\treturn i, true\n", "\t\t\tif false && isComment(toks[j]) {\n\t\t\t\treturn i, true\n", "TestProgramLooksPastADirectoryChange") diff --git a/test/mutation/mutants/H-13_shape.py b/test/mutation/mutants/H-13_shape.py index 078b9f8f..d5c1b5a1 100644 --- a/test/mutation/mutants/H-13_shape.py +++ b/test/mutation/mutants/H-13_shape.py @@ -4,29 +4,29 @@ m("the report stops counting programs at all", "internal/report/report.go", "\t\t\tsess.Declarations.ByProgram[*d.Shape.Program]++", "\t\t\t_ = d.Shape.Program", "TestByProgramAndVerb") -m("H-13 a word the tokenizer cannot vouch for is read as the program", "internal/shape/shape.go", +m("H-13 a word the tokenizer cannot vouch for is read as the program", "internal/shape/program.go", "\t\tcase t.opaque, isComment(t):", "\t\tcase isComment(t):", "TestNoCorpusLineLeaksIntoProgram") m("H-13 the tokenizer never marks an expansion opaque", "internal/shape/tokenize.go", "\tif l.expands(i, false) {\n\t\tl.w.opaque = true\n\t}", "\tif l.expands(i, false) {\n\t\t_ = l.w.opaque\n\t}", "TestNoCorpusLineLeaksIntoProgram") -m("H-13 backslash-newlines are not joined before the program search", "internal/shape/shape.go", +m("H-13 backslash-newlines are not joined before the program search", "internal/shape/join.go", "\tif n == 0 {\n\t\treturn joined{s: s}\n\t}", "\tif true {\n\t\treturn joined{s: s}\n\t}", "TestNoCorpusLineLeaksIntoProgram|TestTokenizeProgram") -m("H-13 a paren where a redirect target belongs is read as a subshell", "internal/shape/shape.go", +m("H-13 a paren where a redirect target belongs is read as a subshell", "internal/shape/words.go", "\tif toks[i+1].meta {\n\t\t// A paren where the target belongs", "\tif false && toks[i+1].meta {\n\t\t// A paren where the target belongs", "TestNoCorpusLineLeaksIntoProgram") -m("H-13 a word quoted before its = is read as an assignment", "internal/shape/shape.go", +m("H-13 a word quoted before its = is read as an assignment", "internal/shape/words.go", "\treturn t.quotedAt < 0 || t.quotedAt > eq", "\treturn true", "TestProgramIsAProgram|TestNoCorpusLineLeaksIntoProgram") -m("H-13 a quoted command line with spaces is recorded as the program", "internal/shape/shape.go", +m("H-13 a quoted command line with spaces is recorded as the program", "internal/shape/program.go", "\t\tcase c < 0x21 || c > 0x7e:", "\t\tcase c < 0x20 || c > 0x7e:", "TestProgramIsAProgram") -m("H-13 program is whatever token came first, operator or not", "internal/shape/shape.go", +m("H-13 program is whatever token came first, operator or not", "internal/shape/program.go", "\t\tt := toks[i]\n\t\tif t.meta {", "\t\tt := toks[i]\n\t\tif false && t.meta {", "TestProgramIsAProgram") -m("H-13 a split redirect operator's second half is taken for its target", "internal/shape/shape.go", +m("H-13 a split redirect operator's second half is taken for its target", "internal/shape/words.go", "toks[i+1].meta && toks[i+1].glued && continuesRedirect(op, toks[i+1].text); n++ {", "toks[i+1].meta && op == \"\"; n++ {", "TestH13_CanaryNeverReachesDisk|TestProgramIsAProgram") -m("H-13 a redirect skips every operator before its target", "internal/shape/shape.go", +m("H-13 a redirect skips every operator before its target", "internal/shape/words.go", "\tfor n := 0; n < pieces && i+1 < len(toks) && toks[i+1].meta && toks[i+1].glued && continuesRedirect(op, toks[i+1].text); n++ {\n\t\ti++\n\t}", "\tfor i+1 < len(toks) && toks[i+1].meta {\n\t\ti++\n\t}\n\t_, _ = op, pieces", "TestH13_CanaryNeverReachesDisk") @@ -35,112 +35,112 @@ # row per glob character, per separator, per glue check and per case of an # unknowable group depth, so that no rule rides on another. PROG = "TestProgramIsAProgram|TestNoCorpusLineLeaksIntoProgram" -m("H-13 a redirect's pieces are joined across a blank or a newline", "internal/shape/shape.go", +m("H-13 a redirect's pieces are joined across a blank or a newline", "internal/shape/words.go", "toks[i+1].meta && toks[i+1].glued && continuesRedirect(op, toks[i+1].text)", "toks[i+1].meta && continuesRedirect(op, toks[i+1].text)", PROG) -m("H-13 an operator where a redirect target belongs is read past to the next command", "internal/shape/shape.go", +m("H-13 an operator where a redirect target belongs is read past to the next command", "internal/shape/words.go", "\treturn !procSub(toks, i+1)\n}", "\treturn false\n}", PROG) m("H-13 the tokenizer never marks an operator glued", "internal/shape/tokenize.go", "nlBefore: l.sawNL, glued: !l.gap && len(l.toks) > 0})", "nlBefore: l.sawNL, glued: false})", "TestTokenizeGlued|TestProgramIsAProgram") m("H-13 the tokenizer never marks a word glued", "internal/shape/tokenize.go", "\t\tw.glued = !l.gap && len(l.toks) > 0\n", "\t\tw.glued = false\n", "TestTokenizeGlued|TestProgramIsAProgram") -m("H-13 a byte the shell does not split on is read as part of a program name", "internal/shape/shape.go", +m("H-13 a byte the shell does not split on is read as part of a program name", "internal/shape/program.go", "\t\tcase c < 0x21 || c > 0x7e:", "\t\tcase c == ' ' || c == '\\t' || c == '\\n' || c == '\\r':", PROG) -m("H-13 a command word holding $ is named", "internal/shape/shape.go", +m("H-13 a command word holding $ is named", "internal/shape/program.go", "\t\tcase c == '$':", "\t\tcase false && c == '$':", PROG) -m("H-13 a glob command word is named", "internal/shape/shape.go", +m("H-13 a glob command word is named", "internal/shape/program.go", "\t\tcase c == '*' || c == '?':", "\t\tcase false && (c == '*' || c == '?'):", PROG) -m("H-13 zsh's ~ exclusion is read as a path", "internal/shape/shape.go", +m("H-13 zsh's ~ exclusion is read as a path", "internal/shape/program.go", "\t\tcase c == '~' && j > 0:", "\t\tcase false && c == '~' && j > 0:", PROG) -m("H-13 an all-digit command word is named", "internal/shape/shape.go", +m("H-13 an all-digit command word is named", "internal/shape/program.go", "\treturn !digits\n}", "\treturn true\n}", PROG) -m("H-13 a word glued to <( or a numeric glob is named by its front", "internal/shape/shape.go", +m("H-13 a word glued to <( or a numeric glob is named by its front", "internal/shape/program.go", "\treturn next.meta && next.text == \"(\" || op == \"<\" && isNumericGlob(next)", "\treturn false && next.meta && op == \"\"", PROG) -m("H-13 a directory is named by its last component", "internal/shape/shape.go", +m("H-13 a directory is named by its last component", "internal/shape/program.go", "\tcase strings.HasSuffix(s, \"/\"):", "\tcase false && strings.HasSuffix(s, \"/\"):", PROG) m("H-13 a control byte in the line is read past", "internal/shape/shape.go", "\t\tif c := cmd[i]; c < 0x20 && c != '\\t' && c != '\\n' || c == 0x7f {", "\t\tif c := cmd[i]; c == '\\r' {", PROG) -m("H-13 the function-definition scan stops at a redirection", "internal/shape/shape.go", +m("H-13 the function-definition scan stops at a redirection", "internal/shape/program.go", "\t\tcase isRedirect(t.text):\n\t\t\tend := operatorEnd(toks, j)\n", "\t\tcase isRedirect(t.text):\n\t\t\tif true {\n\t\t\t\treturn -1, true\n\t\t\t}\n\t\t\tend := operatorEnd(toks, j)\n", PROG) -m("H-13 the function-definition scan reads &> as the background separator", "internal/shape/shape.go", +m("H-13 the function-definition scan reads &> as the background separator", "internal/shape/program.go", "\t\tcase t.text == \"&\" && j+1 < len(toks)", "\t\tcase false && t.text == \"&\" && j+1 < len(toks)", PROG) -m("H-13 the function-definition scan stops at a paren that does not close at once", "internal/shape/shape.go", +m("H-13 the function-definition scan stops at a paren that does not close at once", "internal/shape/program.go", "\t\t\tif j+1 < len(toks) && toks[j+1].meta && toks[j+1].text == \")\" {\n\t\t\t\treturn 0, false\n\t\t\t}\n", "\t\t\tif j+1 < len(toks) && toks[j+1].meta && toks[j+1].text == \")\" {\n\t\t\t\treturn 0, false\n\t\t\t}\n\t\t\treturn -1, true\n", PROG) -m("H-13 the function-definition scan runs on past a newline", "internal/shape/shape.go", +m("H-13 the function-definition scan runs on past a newline", "internal/shape/program.go", "\t\tcase t.nlBefore:\n\t\t\treturn -1, true\n", "\t\tcase false && t.nlBefore:\n\t\t\treturn -1, true\n", "TestProgramIsAProgram") SEPS = ["\";\"", "\"&&\"", "\"||\"", "\"|\"", "\"&\""] _seps = "\t\tcase " + " || ".join("t.text == " + x for x in SEPS) + ":\n\t\t\treturn j, true" for _drop in SEPS: - m("H-13 the function-definition scan reads on past " + _drop.strip('"'), "internal/shape/shape.go", + m("H-13 the function-definition scan reads on past " + _drop.strip('"'), "internal/shape/program.go", _seps, "\t\tcase " + " || ".join("t.text == " + x for x in SEPS if x != _drop) + ":\n\t\t\treturn j, true", PROG) -m("H-13 the function-definition scan does not skip a paren group", "internal/shape/shape.go", +m("H-13 the function-definition scan does not skip a paren group", "internal/shape/program.go", "\t\t\t}\n\t\t\topen = append(open, '(')\n", "\t\t\t}\n", PROG) -m("H-13 the function-definition scan does not skip backticks", "internal/shape/shape.go", +m("H-13 the function-definition scan does not skip backticks", "internal/shape/program.go", "\t\tcase t.ticks%2 == 1:\n\t\t\topen = append(open, '`')\n", "\t\tcase false:\n\t\t\topen = append(open, '`')\n", PROG) -m("H-13 a paren inside a group does not nest", "internal/shape/shape.go", +m("H-13 a paren inside a group does not nest", "internal/shape/program.go", "\tcase t.text == \"(\":\n\t\treturn append(open, '(')\n", "\tcase t.text == \"(\":\n", PROG) -m("H-13 a paren group never closes", "internal/shape/shape.go", +m("H-13 a paren group never closes", "internal/shape/program.go", "\tcase t.text == \")\":\n\t\treturn open[:len(open)-1]\n", "\tcase t.text == \")\":\n", PROG) -m("H-13 backticks never close", "internal/shape/shape.go", +m("H-13 backticks never close", "internal/shape/program.go", "\tcase t.ticks%2 == 1 && top == '`':\n\t\treturn open[:len(open)-1]\n", "\tcase false:\n\t\treturn open[:len(open)-1]\n", PROG) -m("H-13 a group that never closes is read as closed", "internal/shape/shape.go", +m("H-13 a group that never closes is read as closed", "internal/shape/program.go", "\tif len(open) > 0 || uncertain {\n\t\treturn 0, false\n\t}", "\tif uncertain {\n\t\treturn 0, false\n\t}", PROG) -m("H-13 the function-definition scan reads past where the lexer's reading stopped", "internal/shape/shape.go", +m("H-13 the function-definition scan reads past where the lexer's reading stopped", "internal/shape/program.go", "\tif len(open) > 0 || uncertain {\n\t\treturn 0, false\n\t}", "\tif len(open) > 0 {\n\t\treturn 0, false\n\t}", PROG) -m("H-13 a ${ the word does not close is read past", "internal/shape/shape.go", +m("H-13 a ${ the word does not close is read past", "internal/shape/program.go", "\t\tif openBrace(t) {\n\t\t\treturn 0, false\n\t\t}", "\t\tif false && openBrace(t) {\n\t\t\treturn 0, false\n\t\t}", PROG) -m("H-13 a closed ${ } is taken for an open one", "internal/shape/shape.go", +m("H-13 a closed ${ } is taken for an open one", "internal/shape/program.go", "strings.Count(t.text[k:], \"{\") > strings.Count(t.text[k:], \"}\")", "strings.Count(t.text[k:], \"{\") >= strings.Count(t.text[k:], \"}\")", "TestProgramIsAProgram") -m("H-13 a quoted ${ is taken for an expansion", "internal/shape/shape.go", +m("H-13 a quoted ${ is taken for an expansion", "internal/shape/program.go", "\treturn t.opaque && k >= 0 &&", "\treturn k >= 0 &&", "TestProgramIsAProgram") -m("H-13 a comment inside a group is read past", "internal/shape/shape.go", +m("H-13 a comment inside a group is read past", "internal/shape/program.go", "\tcase isComment(t):\n\t\treturn true\n", "\tcase false && isComment(t):\n\t\treturn true\n", PROG) -m("H-13 a case statement inside a group is read past", "internal/shape/shape.go", +m("H-13 a case statement inside a group is read past", "internal/shape/program.go", "\tcase !t.meta && t.quotedAt < 0 && t.text == \"case\":\n", "\tcase false:\n", PROG) -m("H-13 a here-document inside a group is read past", "internal/shape/shape.go", +m("H-13 a here-document inside a group is read past", "internal/shape/program.go", "\tcase hereDoc(toks, j):\n\t\treturn true\n", "\tcase false && hereDoc(toks, j):\n\t\treturn true\n", PROG) -m("H-13 a newline in a group after a here-document is read past", "internal/shape/shape.go", +m("H-13 a newline in a group after a here-document is read past", "internal/shape/program.go", "\tcase heredoc && t.nlBefore:\n", "\tcase false:\n", PROG) -m("H-13 a here-string is taken for a here-document", "internal/shape/shape.go", +m("H-13 a here-string is taken for a here-document", "internal/shape/program.go", "\treturn next >= len(toks) || !(toks[next].meta && toks[next].glued && toks[next].text == \"<\")", "\treturn next >= 0", "TestProgramIsAProgram") m("H-13 a trailing backslash after an expansion is read as a real one", "internal/shape/tokenize.go", "\t\tl.flush()\n\t\treturn i, l.unterminated()", "\t\tl.flush()\n\t\treturn i, errUnterminated", "TestTokenizeProgramStops|" + PROG) -m("H-13 the function-definition scan reads &> as & and then >", "internal/shape/shape.go", +m("H-13 the function-definition scan reads &> as & and then >", "internal/shape/program.go", "\t\t\tj++\n\t\t\tfallthrough", "\t\t\tfallthrough", "TestProgramIsAProgram") -m("H-13 a word glued to any operator and a paren is named by its front", "internal/shape/shape.go", +m("H-13 a word glued to any operator and a paren is named by its front", "internal/shape/program.go", "\tif op != \"<\" && op != \">\" {", "\tif false && op != \"<\" && op != \">\" {", "TestProgramIsAProgram") -m("H-13 a word glued to > and digits is read as a numeric glob", "internal/shape/shape.go", +m("H-13 a word glued to > and digits is read as a numeric glob", "internal/shape/program.go", "|| op == \"<\" && isNumericGlob(next)", "|| isNumericGlob(next)", "TestProgramIsAProgram") -m("H-13 a backtick inside a paren group opens nothing", "internal/shape/shape.go", +m("H-13 a backtick inside a paren group opens nothing", "internal/shape/program.go", "\tcase t.ticks%2 == 1:\n\t\treturn append(open, '`')\n", "\tcase false:\n\t\treturn append(open, '`')\n", PROG) -m("H-13 a paren inside backticks closes them", "internal/shape/shape.go", +m("H-13 a paren inside backticks closes them", "internal/shape/program.go", "\tcase top == '`' || !t.meta:", "\tcase !t.meta:", PROG) -m("H-13 & then > is read as &>", "internal/shape/shape.go", +m("H-13 & then > is read as &>", "internal/shape/program.go", "toks[j+1].meta && toks[j+1].glued && strings.HasPrefix(toks[j+1].text, \">\")", "toks[j+1].meta && strings.HasPrefix(toks[j+1].text, \">\")", "TestProgramIsAProgram") -m("H-13 a numeric glob spaced from the command word is read as part of it", "internal/shape/shape.go", +m("H-13 a numeric glob spaced from the command word is read as part of it", "internal/shape/program.go", "!toks[i+1].meta || !toks[i+1].glued || !toks[i+2].glued", "!toks[i+1].meta || !toks[i+2].glued", "TestProgramIsAProgram") -m("H-13 digits spaced from a glued < are read as a numeric glob", "internal/shape/shape.go", +m("H-13 digits spaced from a glued < are read as a numeric glob", "internal/shape/program.go", "!toks[i+1].meta || !toks[i+1].glued || !toks[i+2].glued", "!toks[i+1].meta || !toks[i+1].glued", "TestProgramIsAProgram") -m("H-13 a path ending in . is named", "internal/shape/shape.go", +m("H-13 a path ending in . is named", "internal/shape/program.go", "\tcase s != \".\" && (path.Base(s) == \".\" || path.Base(s) == \"..\"):", "\tcase s != \".\" && path.Base(s) == \"..\":", PROG) -m("H-13 a path ending in .. is named", "internal/shape/shape.go", +m("H-13 a path ending in .. is named", "internal/shape/program.go", "\tcase s != \".\" && (path.Base(s) == \".\" || path.Base(s) == \"..\"):", "\tcase s != \".\" && path.Base(s) == \".\":", PROG) -m("H-13 the source builtin is refused as a directory", "internal/shape/shape.go", +m("H-13 the source builtin is refused as a directory", "internal/shape/program.go", "\tcase s != \".\" && (path.Base(s) == \".\" || path.Base(s) == \"..\"):", "\tcase path.Base(s) == \".\" || path.Base(s) == \"..\":", "TestProgramIsAProgram") -m("H-13 a jobspec is named", "internal/shape/shape.go", +m("H-13 a jobspec is named", "internal/shape/program.go", "\tcase strings.HasPrefix(s, \"%\"):", "\tcase false && strings.HasPrefix(s, \"%\"):", PROG) -m("H-13 a quoted command word split by a tab is named", "internal/shape/shape.go", +m("H-13 a quoted command word split by a tab is named", "internal/shape/program.go", "\t\tcase c < 0x21 || c > 0x7e:", "\t\tcase c < 0x21 && c != '\\t' || c > 0x7e:", PROG) -m("H-13 a quoted command word split by a newline is named", "internal/shape/shape.go", +m("H-13 a quoted command word split by a newline is named", "internal/shape/program.go", "\t\tcase c < 0x21 || c > 0x7e:", "\t\tcase c < 0x21 && c != '\\n' || c > 0x7e:", PROG) m("H-13 a tab in the line is taken for a control byte", "internal/shape/shape.go", "\t\tif c := cmd[i]; c < 0x20 && c != '\\t' && c != '\\n' || c == 0x7f {", @@ -179,7 +179,7 @@ "\t\t\tif i+1 >= len(s) || r.continuesAt(i) {", "\t\t\tif i+1 >= len(s) {", TPROG) m("H-13 a backslash-newline the joining left is read past in its double quotes", C, "\t\t\tif r.continuesAt(i) {", "\t\t\tif false {", TPROG) -m("H-13 the joining's offsets are not kept", "internal/shape/shape.go", +m("H-13 the joining's offsets are not kept", "internal/shape/join.go", "\t\t\t\t\tjoins = append(joins, b.Len())\n", "", TPROG) m("H-13 a quoted span joined across a newline opens a here-document's body", C, " || r.joinedIn(from, to)))", "))", TPROG) @@ -217,21 +217,21 @@ m("H-13 an expansion in arithmetic is read as parens", C, "\t\t\tdollarAt = i\n\t\t\tend := r.dollarOpens(i, dollars, nest+1)\n\t\t\tif end < 0 {\n\t\t\t\treturn -1\n\t\t\t}\n\t\t\ti = end", "\t\t\tdollarAt = i\n\t\t\tend := i\n\t\t\tif end < 0 {\n\t\t\t\treturn -1\n\t\t\t}\n\t\t\ti = end", TPROG) -m("H-13 the function-definition scan reads past a redirect with no target", "internal/shape/shape.go", +m("H-13 the function-definition scan reads past a redirect with no target", "internal/shape/program.go", "\t\t\tif noTarget(toks, end) {", "\t\t\tif false && noTarget(toks, end) {", PROG) -m("H-13 the program search reads past a redirect with no target", "internal/shape/shape.go", +m("H-13 the program search reads past a redirect with no target", "internal/shape/words.go", "\tif noTarget(toks, i) {\n\t\treturn 0, false\n\t}\n", "\tif false && noTarget(toks, i) {\n\t\treturn 0, false\n\t}\n", PROG) -m("H-13 a redirect takes its target from the next line", "internal/shape/shape.go", +m("H-13 a redirect takes its target from the next line", "internal/shape/words.go", "\tcase next.nlBefore:\n\t\treturn true\n", "\tcase false && next.nlBefore:\n\t\treturn true\n", PROG) -m("H-13 a redirect followed by another is read past", "internal/shape/shape.go", +m("H-13 a redirect followed by another is read past", "internal/shape/words.go", "\treturn !procSub(toks, i+1)\n}", "\treturn !procSub(toks, i+1) && !isRedirect(next.text)\n}", PROG) -m("H-13 a paren is refused as a redirect's missing target", "internal/shape/shape.go", +m("H-13 a paren is refused as a redirect's missing target", "internal/shape/words.go", "\tcase !next.meta, next.text == \"(\":\n", "\tcase !next.meta:\n", "TestProgramIsAProgram") -m("H-13 a process substitution is refused as a redirect's target", "internal/shape/shape.go", +m("H-13 a process substitution is refused as a redirect's target", "internal/shape/words.go", "\treturn !procSub(toks, i+1)\n}", "\treturn true\n}", "TestProgramIsAProgram") -m("H-13 a paren spaced from its < is read as a process substitution", "internal/shape/shape.go", +m("H-13 a paren spaced from its < is read as a process substitution", "internal/shape/words.go", "\treturn next.meta && next.glued && next.text == \"(\"\n}", "\treturn next.meta && next.text == \"(\"\n}", PROG) -m("H-13 a comment where a redirect target belongs is taken for the target", "internal/shape/shape.go", +m("H-13 a comment where a redirect target belongs is taken for the target", "internal/shape/words.go", "\tcase isComment(next):\n\t\treturn true\n", "\tcase false && isComment(next):\n\t\treturn true\n", PROG) m("H-13 a C0 control other than NUL and CR is read past", "internal/shape/shape.go", "\t\tif c := cmd[i]; c < 0x20 && c != '\\t' && c != '\\n' || c == 0x7f {", @@ -239,14 +239,14 @@ m("H-13 a DEL in the line is read past", "internal/shape/shape.go", "\t\tif c := cmd[i]; c < 0x20 && c != '\\t' && c != '\\n' || c == 0x7f {", "\t\tif c := cmd[i]; c < 0x20 && c != '\\t' && c != '\\n' {", PROG) -m("H-13 ~user is named", "internal/shape/shape.go", +m("H-13 ~user is named", "internal/shape/program.go", "\tcase strings.HasPrefix(s, \"~\") && !strings.Contains(s, \"/\"):", "\tcase false && strings.HasPrefix(s, \"~\") && !strings.Contains(s, \"/\"):", PROG) -m("H-13 zsh's ^ is read as part of a program name", "internal/shape/shape.go", +m("H-13 zsh's ^ is read as part of a program name", "internal/shape/program.go", "\t\tcase c == '^' || c == '#':", "\t\tcase c == '#':", PROG) -m("H-13 zsh's # is read as part of a program name", "internal/shape/shape.go", +m("H-13 zsh's # is read as part of a program name", "internal/shape/program.go", "\t\tcase c == '^' || c == '#':", "\t\tcase c == '^':", PROG) -m("H-13 an empty word is named `.`", "internal/shape/shape.go", +m("H-13 an empty word is named `.`", "internal/shape/program.go", "which reads as the source builtin.\n\t\treturn false", "which reads as the source builtin.\n\t\treturn true", PROG) m("H-13 the program search reads $'...' as argc does", "internal/shape/shape.go", diff --git a/test/mutation/mutants/LK_account.py b/test/mutation/mutants/LK_account.py index 39e7b715..30048427 100644 --- a/test/mutation/mutants/LK_account.py +++ b/test/mutation/mutants/LK_account.py @@ -44,14 +44,14 @@ "TestJoin_ANoneSaysAFailedLookupAloneIsNotCounted|TestJoin_AFailedCallWhoseDeclarationWasLostIsNotJudged") # One list of lookup tools: a tool added to it, or to the check alone, is # silenced and must fail a test; the report must name every one. -m("LK a tool is added to the read class", "internal/shape/shape.go", +m("LK a tool is added to the read class", "internal/shape/verb.go", "var readTools = []string{\"Read\", \"Glob\", \"Grep\", \"NotebookRead\"}", "var readTools = []string{\"Read\", \"Glob\", \"Grep\", \"NotebookRead\", \"LS\"}", "TestIsLookup_TheReportNamesExactlyTheToolsItCounts") -m("LK a tool is classed read beside the list", "internal/shape/shape.go", +m("LK a tool is classed read beside the list", "internal/shape/verb.go", "\tif slices.Contains(readTools, name) {", "\tif slices.Contains(readTools, name) || name == \"LS\" {", "TestIsLookup_TheReportNamesExactlyTheToolsItCounts") -m("LK a tool is classed read in shape's switch", "internal/shape/shape.go", +m("LK a tool is classed read in shape's switch", "internal/shape/verb.go", "\tswitch name {\n\tcase \"Write\", \"Edit\", \"MultiEdit\", \"NotebookEdit\":", "\tswitch name {\n\tcase \"LSP\":\n\t\treturn VerbRead\n\tcase \"Write\", \"Edit\", \"MultiEdit\", \"NotebookEdit\":", "TestIsLookup_TheReportNamesExactlyTheToolsItCounts") diff --git a/test/mutation/mutants/MS_masked.py b/test/mutation/mutants/MS_masked.py index 17a933ac..969fd290 100644 --- a/test/mutation/mutants/MS_masked.py +++ b/test/mutation/mutants/MS_masked.py @@ -109,7 +109,7 @@ "\t{\"make\"}, {\"gmake\"},\n", "\t{\"gmake\"},\n", SH) m("MS a build masked before a test wins", "internal/shape/masked.go", "\t\t\tif p.runners[g].kind == MaskedTest {\n\t\t\t\tk = MaskedTest\n", "\t\t\tif p.runners[g].kind == MaskedTest && false {\n\t\t\t\tk = MaskedTest\n", SH) -m("MS the runner's arguments run to the end of the line", "internal/shape/shape.go", +m("MS the runner's arguments run to the end of the line", "internal/shape/verb.go", "\t\treturn !refusesRun(refusalsOf(c), toks[:i], toks[i+len(c):end])", "\t\treturn !refusesRun(refusalsOf(c), toks[:i], toks[i+len(c):])", SH) m("MS here-document bodies are read as commands", "internal/shape/tokenize.go", "\t\tl.pending = append(l.pending, j)\n", "\t\t_ = j\n", SH) @@ -131,11 +131,11 @@ "\t\tif s.op != opCd || s.start < 3*folded && s.start%3 == 0 {", "\t\tif s.op != opCd {", SH) m("MS an expansion in the runner's words loses its digest", "internal/shape/masked.go", "\t\tif t.opaque {\n\t\t\tw = \"\\x02\" + w\n\t\t}\n", "\t\tif t.opaque {\n\t\t\treturn \"\"\n\t\t}\n", SH) -m("MS a backslash at a comment's end joins the next line", "internal/shape/shape.go", +m("MS a backslash at a comment's end joins the next line", "internal/shape/join.go", "\t\tcase comments && c == '#' && !inSingle && !inDouble && wordStart(b.String()):", "\t\tcase false:", SH) -m("MS mvn's build row refuses -DskipTests", "internal/shape/shape.go", +m("MS mvn's build row refuses -DskipTests", "internal/shape/verb.go", "\t\tif len(c) == 1 {\n\t\t\t// The build row: a flag that skips the tests still builds.\n\t\t\treturn \"mvn build\"\n\t\t}\n", "", SH) -m("MS python -m unittest is not a test runner", "internal/shape/shape.go", +m("MS python -m unittest is not a test runner", "internal/shape/verb.go", "\t{\"python\", \"-m\", \"unittest\"}, {\"python3\", \"-m\", \"unittest\"},\n", "", "TestUnittestIsATestRunner") RP = "TestMasked_" diff --git a/test/mutation/mutants/RES-10_leading-cd.py b/test/mutation/mutants/RES-10_leading-cd.py index 9a9eb252..20ac96e8 100644 --- a/test/mutation/mutants/RES-10_leading-cd.py +++ b/test/mutation/mutants/RES-10_leading-cd.py @@ -6,10 +6,10 @@ # token bound's arithmetic the package does not compile, by design. m("RES-10 the hook folds every leading cd", "internal/hook/handle.go", "const maxLeadingCd = shape.MaxLeadingCd\n", "const maxLeadingCd = 1 << 30\n", "TestRES10_") -m("RES-10 the shared leading-cd bound is lifted", "internal/shape/shape.go", +m("RES-10 the shared leading-cd bound is lifted", "internal/shape/leading.go", "const MaxLeadingCd = 64\n", "const MaxLeadingCd = 128\n", "TestRES10_") -m("RES-10 a runner past the fold is paired as though its directory were known", "internal/shape/shape.go", +m("RES-10 a runner past the fold is paired as though its directory were known", "internal/shape/leading.go", "\tif len(dirs) > MaxLeadingCd {\n\t\treturn 0\n\t}\n", "", "TestRES10_RunnerPastTheFoldIsNotPaired") -m("RES-10 a line cut at the token bound gives up its leading cds", "internal/shape/shape.go", +m("RES-10 a line cut at the token bound gives up its leading cds", "internal/shape/leading.go", "\tif err != nil && err != errTokenBound {\n\t\treturn nil, false\n\t}\n", "\tif err != nil {\n\t\treturn nil, false\n\t}\n", "TestRES10_") diff --git a/test/mutation/mutants/RES-29_shape-memory.py b/test/mutation/mutants/RES-29_shape-memory.py index bbb9dc1d..1b6d0c4f 100644 --- a/test/mutation/mutants/RES-29_shape-memory.py +++ b/test/mutation/mutants/RES-29_shape-memory.py @@ -11,9 +11,9 @@ m("RES-29 a copied word grows a quarter at a time", "internal/shape/tokenize.go", "\tw.text.Grow(to - from)\n\tw.text.WriteString(s[from:to])\n", "\tw.text.WriteString(s[from:to])\n", "TestRES29_") -m("RES-29 every removed backslash-newline is kept", "internal/shape/shape.go", +m("RES-29 every removed backslash-newline is kept", "internal/shape/join.go", "\t\t\t\tif len(joins) < maxJoins {\n", "\t\t\t\tif true {\n", "TestRES29_") -m("RES-29 dropped offsets are forgotten", "internal/shape/shape.go", +m("RES-29 dropped offsets are forgotten", "internal/shape/join.go", "\t\t\t\t} else {\n\t\t\t\t\tlost = true\n\t\t\t\t}\n", "\t\t\t\t}\n", "TestRES29_") m("RES-29 a span past the kept offsets is read as certain", "internal/shape/comsub.go", "\treturn r.lost && to >= r.joins[len(r.joins)-1]\n", "\treturn false\n", "TestRES29_") diff --git a/test/mutation/mutants/RES-5_fold-keys.py b/test/mutation/mutants/RES-5_fold-keys.py index c6c68307..42104b3c 100644 --- a/test/mutation/mutants/RES-5_fold-keys.py +++ b/test/mutation/mutants/RES-5_fold-keys.py @@ -13,7 +13,7 @@ "var payloadKeys = jsonscan.NewFoldKeySet(KeyNames()...)\n", "var payloadKeys = jsonscan.NewKeySet(KeyNames()...)\n", "TestRES5_") -m("RES-5 run_in_background is matched exactly", "internal/shape/shape.go", +m("RES-5 run_in_background is matched exactly", "internal/shape/verb.go", "var runInBackgroundKey = jsonscan.NewFoldKeySet(\"run_in_background\")\n", "var runInBackgroundKey = jsonscan.NewKeySet(\"run_in_background\")\n", "TestRES5_") diff --git a/test/mutation/mutants/TB_shape.py b/test/mutation/mutants/TB_shape.py index 877713fa..21270123 100644 --- a/test/mutation/mutants/TB_shape.py +++ b/test/mutation/mutants/TB_shape.py @@ -2,21 +2,21 @@ "\t\t\tif runsTests(pshaped, i, prog, perr == nil) && !backgrounded(toolInput) {", "\t\t\tif false && runsTests(pshaped, i, prog, perr == nil) && !backgrounded(toolInput) {", "TestTestRunnerIsRecognised|TestTestClassCarriesNoContent|TestTestBending_") -m("TB a line the lexer could not finish is vouched for", "internal/shape/shape.go", +m("TB a line the lexer could not finish is vouched for", "internal/shape/verb.go", "\tif !whole {\n\t\treturn false\n\t}", "\t_ = whole", "TestTestRunnerIsRecognised") -m("TB a quoted argument is compared as plain", "internal/shape/shape.go", +m("TB a quoted argument is compared as plain", "internal/shape/verb.go", "\t\t\tif t.quotedAt >= 0 || t.text != want || runsOn(toks, k) {", "\t\t\tif t.text != want || runsOn(toks, k) {", "TestTestRunnerIsRecognised") -m("TB an argument running into a process substitution is plain", "internal/shape/shape.go", +m("TB an argument running into a process substitution is plain", "internal/shape/verb.go", "\t\t\tif t.quotedAt >= 0 || t.text != want || runsOn(toks, k) {", "\t\t\tif t.quotedAt >= 0 || t.text != want {", "TestTestRunnerIsRecognised") -m("TB every word of a runner is compared against the first argument", "internal/shape/shape.go", +m("TB every word of a runner is compared against the first argument", "internal/shape/verb.go", "\t\t\tk := i + 1 + n\n", "\t\t\tk := i + 1 + n*0\n", "TestTestRunnerIsRecognised") # Re-anchored after the masked-status refactor: the row match moved into # runnerOn, which compares one command's words up to its end. -m("TB a runner missing its argument still matches", "internal/shape/shape.go", +m("TB a runner missing its argument still matches", "internal/shape/verb.go", "\t\t\tif k >= end {\n\t\t\t\tcontinue next\n", "\t\t\tif k >= end {\n\t\t\t\tbreak\n", "TestTestRunnerIsRecognised") -m("TB go test is not on the list", "internal/shape/shape.go", +m("TB go test is not on the list", "internal/shape/verb.go", "{\"go\", \"test\"}, {\"cargo\", \"test\"}", "{\"go\", \"tset\"}, {\"cargo\", \"test\"}", "TestTestRunnerIsRecognised|TestTestBending_OnlyTestFiles") m("TB the test class is missing from the vocabulary", "internal/shape/shape.go", @@ -161,10 +161,10 @@ "TestBackgroundLaunchIsNotATestRun|TestTestBending_ShapesFromDerive") # Re-anchored after the masked-status refactor: runsTests now ends in one return # joining runnerOn and wholeCommand. -m("TB a runner piped or listed into another command is a test run", "internal/shape/shape.go", +m("TB a runner piped or listed into another command is a test run", "internal/shape/verb.go", "testCommands) && wholeCommand(toks, i)\n", "testCommands) && wholeCommand(toks[:i+1], i)\n", "TestTestRunnerIsRecognised|TestTestBending_ShapesFromDerive") -m("TB a command on the runner's next line is not seen", "internal/shape/shape.go", +m("TB a command on the runner's next line is not seen", "internal/shape/verb.go", "\t\tif t.nlBefore {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}", "\t\tif t.nlBefore && false {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}", "TestTestRunnerIsRecognised") @@ -206,57 +206,57 @@ "testEdits: testEdits, otherEdits: otherEdits - 1, anyEdits: anyEdits - 1,\n", "TestTestBending") -m("TB make check is a test run", "internal/shape/shape.go", +m("TB make check is a test run", "internal/shape/verb.go", "{\"gradlew\", \"test\"}, {\"make\", \"test\"},\n", "{\"gradlew\", \"test\"}, {\"make\", \"test\"}, {\"make\", \"check\"},\n", "TestTestClassRefusesWhatDoesNotRunTests") # Re-anchored after the masked-status refactor: the refusal moved into runnerOn, # over the runner's own command (toks up to end); the next two as well. -m("TB an argument that does not run the tests is ignored", "internal/shape/shape.go", +m("TB an argument that does not run the tests is ignored", "internal/shape/verb.go", "\t\treturn !refusesRun(refusalsOf(c), toks[:i], toks[i+len(c):end])", "\t\treturn !(false && refusesRun(refusalsOf(c), toks[:i], toks[i+len(c):end]))", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB the refusal reads only the first argument", "internal/shape/shape.go", +m("TB the refusal reads only the first argument", "internal/shape/verb.go", "\t\treturn !refusesRun(refusalsOf(c), toks[:i], toks[i+len(c):end])", "\t\treturn !refusesRun(refusalsOf(c), toks[:i], toks[i+len(c):min(i+len(c)+1, end)])", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB a prefix entry is compared whole", "internal/shape/shape.go", +m("TB a prefix entry is compared whole", "internal/shape/verb.go", "\t\t\tif strings.HasPrefix(word, p) {\n", "\t\t\tif word == p {\n", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB the per-runner list is not read", "internal/shape/shape.go", +m("TB the per-runner list is not read", "internal/shape/verb.go", "\tlist := notARun[strings.TrimSuffix(runner, \" build\")]\n", "\tlist := []string(nil)\n", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB help, version and watch refuse nothing", "internal/shape/shape.go", +m("TB help, version and watch refuse nothing", "internal/shape/verb.go", "|| onList(t.text, notARunAny)", "|| onList(t.text, nil)", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB tox with a target is a test run", "internal/shape/shape.go", +m("TB tox with a target is a test run", "internal/shape/verb.go", "\"tox\": {\"-e*\", ", "\"tox\": {", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB nox with a session is a test run", "internal/shape/shape.go", +m("TB nox with a session is a test run", "internal/shape/verb.go", "\"nox\": {\"-s*\", ", "\"nox\": {", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB go test -c is a test run", "internal/shape/shape.go", +m("TB go test -c is a test run", "internal/shape/verb.go", "\"go\": {\"-c\", ", "\"go\": {", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB pytest --collect-only is a test run", "internal/shape/shape.go", +m("TB pytest --collect-only is a test run", "internal/shape/verb.go", "\t\"--collect-only\", \"--co\", ", "\t", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB cargo test --no-run is a test run", "internal/shape/shape.go", +m("TB cargo test --no-run is a test run", "internal/shape/verb.go", "\"cargo\": {\"--no-run\", ", "\"cargo\": {", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB jest --listTests is a test run", "internal/shape/shape.go", +m("TB jest --listTests is a test run", "internal/shape/verb.go", "\"jest\": {\"--listTests\", ", "\"jest\": {", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB watch mode is a test run", "internal/shape/shape.go", +m("TB watch mode is a test run", "internal/shape/verb.go", "\"--version\", \"--watch\", \"--watchAll\"}", "\"--version\"}", "TestTestClassRefusesWhatDoesNotRunTests") @@ -318,48 +318,48 @@ "\tif false && noWrite[d.ToolName] {\n", "TestTestBending") -m("TB a flag's =value form is not refused", "internal/shape/shape.go", +m("TB a flag's =value form is not refused", "internal/shape/verb.go", "\t\t} else if hasValue && strings.HasPrefix(w, \"-\") && name == w && !isFalse(value) {", "\t\t} else if false && hasValue && strings.HasPrefix(w, \"-\") && name == w && !isFalse(value) {", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB a flag set =false is refused", "internal/shape/shape.go", +m("TB a flag set =false is refused", "internal/shape/verb.go", "\t\t} else if hasValue && strings.HasPrefix(w, \"-\") && name == w && !isFalse(value) {", "\t\t} else if hasValue && strings.HasPrefix(w, \"-\") && name == w && (!isFalse(value) || true) {", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB make test -n is a test run", "internal/shape/shape.go", +m("TB make test -n is a test run", "internal/shape/verb.go", "\"make\": {\"-n\", \"--just-print\", \"--dry-run\", \"--recon\", \"-q\", \"--question\", ", "\"make\": {\"--recon\", ", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB make test -v is a test run", "internal/shape/shape.go", +m("TB make test -v is a test run", "internal/shape/verb.go", "\"-t\", \"--touch\", \"-v\"},", "\"-t\", \"--touch\"},", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB gradle continuous mode is a test run", "internal/shape/shape.go", +m("TB gradle continuous mode is a test run", "internal/shape/verb.go", "\"gradle\": {\"--dry-run\", \"-m\", \"-v\", \"-t\", \"--continuous\"},", "\"gradle\": {\"--dry-run\", \"-m\", \"-v\"},", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB mvn test -v is a test run", "internal/shape/shape.go", +m("TB mvn test -v is a test run", "internal/shape/verb.go", "\t\"mvn\": {\"-v\"},\n", "\t\"mvn\": {},\n", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB tox --notest is a test run", "internal/shape/shape.go", +m("TB tox --notest is a test run", "internal/shape/verb.go", "\"--help-ini\", \"--notest\",", "\"--help-ini\",", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB tox devenv is a test run", "internal/shape/shape.go", +m("TB tox devenv is a test run", "internal/shape/verb.go", ", \"devenv\", \"d\"},", "},", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB nox --install-only is a test run", "internal/shape/shape.go", +m("TB nox --install-only is a test run", "internal/shape/verb.go", ", \"--install-only\"},", "},", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB PYTEST_ADDOPTS on the line is not read", "internal/shape/shape.go", +m("TB PYTEST_ADDOPTS on the line is not read", "internal/shape/verb.go", "\t\t\tif strings.HasPrefix(t.text, \"PYTEST_ADDOPTS=\") {", "\t\t\tif false && strings.HasPrefix(t.text, \"PYTEST_ADDOPTS=\") {", "TestTestClassRefusesWhatDoesNotRunTests") # Re-anchored after the masked-status refactor: the refusal is in runnerOn. -m("TB the words before the runner are not read", "internal/shape/shape.go", +m("TB the words before the runner are not read", "internal/shape/verb.go", "\t\treturn !refusesRun(refusalsOf(c), toks[:i], toks[i+len(c):end])", "\t\treturn !refusesRun(refusalsOf(c), toks[i:i], toks[i+len(c):end])", "TestTestClassRefusesWhatDoesNotRunTests") @@ -480,31 +480,31 @@ "\ts.MayWrite = true\n\tif i, ok := programToken", "\ts.MayWrite = false\n\tif i, ok := programToken", "TestMayWrite") -m("TB find -delete and -exec do not write", "internal/shape/shape.go", +m("TB find -delete and -exec do not write", "internal/shape/verb.go", "\t\tcase \"-delete\", \"-exec\", \"-execdir\", \"-ok\", \"-okdir\":\n", "\t\tcase \"-okdir\":\n", "TestMayWrite|TestTestBending_WriteCapable") -m("TB xargs, tee, rsync and scp do not write", "internal/shape/shape.go", +m("TB xargs, tee, rsync and scp do not write", "internal/shape/verb.go", "\t\tcase \"xargs\", \"tee\", \"wget\", \"rsync\", \"scp\":\n", "\t\tcase \"wget\":\n", "TestMayWrite|TestTestBending_WriteCapable") -m("TB curl with an output flag does not write", "internal/shape/shape.go", +m("TB curl with an output flag does not write", "internal/shape/verb.go", "\tif curl && curlOut {\n", "\tif false && curl && curlOut {\n", "TestMayWrite|TestTestBending_WriteCapable") -m("TB a later stage is not looked at", "internal/shape/shape.go", +m("TB a later stage is not looked at", "internal/shape/verb.go", "\t\tif !ok || verbForProgram(path.Base(toks[start+k].text)) != VerbRead {\n", "\t\tif false && (!ok || verbForProgram(path.Base(toks[start+k].text)) != VerbRead) {\n", "TestMayWrite") -m("TB a descriptor duplication is a write", "internal/shape/shape.go", +m("TB a descriptor duplication is a write", "internal/shape/verb.go", "\tif dup && (target == \"-\" || isFDPrefix(toks[end+1])) {\n", "\tif false && dup && (target == \"-\" || isFDPrefix(toks[end+1])) {\n", "TestMayWrite") -m("TB /dev/null is a file written", "internal/shape/shape.go", +m("TB /dev/null is a file written", "internal/shape/verb.go", "\tcase \"/dev/null\", \"/dev/stdout\", \"/dev/stderr\", \"/dev/tty\":\n", "\tcase \"/dev/stdout\", \"/dev/stderr\", \"/dev/tty\":\n", "TestMayWrite") -m("TB the & of 2>&1 separates stages", "internal/shape/shape.go", +m("TB the & of 2>&1 separates stages", "internal/shape/verb.go", "(toks[j-1].text == \">\" || toks[j-1].text == \">>\" || toks[j-1].text == \"|\")", "(toks[j-1].text == \"|\")", "TestMayWrite") @@ -513,31 +513,31 @@ "", "TestTestRuns_") -m("TB a runner prefix is not stepped past", "internal/shape/shape.go", +m("TB a runner prefix is not stepped past", "internal/shape/verb.go", "\ti, prog, ok := runnerPrefix(toks, i, prog)\n", "\t_, _, ok := runnerPrefix(toks, i, prog)\n", "TestTestRunnerIsRecognised") -m("TB time is not stepped past", "internal/shape/shape.go", +m("TB time is not stepped past", "internal/shape/verb.go", "\tif prog == \"time\" {\n", "\tif false && prog == \"time\" {\n", "TestTestRunnerIsRecognised") -m("TB timeout takes any word for its duration", "internal/shape/shape.go", +m("TB timeout takes any word for its duration", "internal/shape/verb.go", "\t\tif !plain(i+1) || !isDuration(toks[i+1].text) || !word(i+2) {\n", "\t\tif !plain(i+1) || !word(i+2) {\n", "TestTestRunnerIsRecognised") -m("TB a wrapped runner is refused on the wrapper's list", "internal/shape/shape.go", +m("TB a wrapped runner is refused on the wrapper's list", "internal/shape/verb.go", "\tswitch c[0] {\n\tcase \"npx\", \"uv\", \"poetry\", \"bundle\":\n", "\tswitch \"\" {\n\tcase \"npx\", \"uv\", \"poetry\", \"bundle\":\n", "TestTestClassRefusesWhatDoesNotRunTests") -m("TB the npx, uv, poetry and bundle forms are off the list", "internal/shape/shape.go", +m("TB the npx, uv, poetry and bundle forms are off the list", "internal/shape/verb.go", "\t{\"npx\", \"jest\"}, {\"npx\", \"vitest\"}, {\"uv\", \"run\", \"pytest\"}, {\"poetry\", \"run\", \"pytest\"},\n\t{\"bundle\", \"exec\", \"rspec\"},\n", "", "TestTestRunnerIsRecognised") -m("TB gradlew and mvnw are off the list", "internal/shape/shape.go", +m("TB gradlew and mvnw are off the list", "internal/shape/verb.go", "{\"mvnw\", \"test\"}, {\"gradle\", \"test\"}, {\"gradlew\", \"test\"}, ", "{\"gradle\", \"test\"}, ", "TestTestRunnerIsRecognised") -m("TB npm t is off the list", "internal/shape/shape.go", +m("TB npm t is off the list", "internal/shape/verb.go", "{\"npm\", \"t\"}, ", "", "TestTestRunnerIsRecognised") @@ -550,51 +550,51 @@ "\tif o != store.ExecFailed {\n", "TestTestBending") -m("TB curl's attached output file hides the flag", "internal/shape/shape.go", +m("TB curl's attached output file hides the flag", "internal/shape/verb.go", "\t\t\tcase c == 'o' || c == 'O':\n\t\t\t\treturn true\n\t\t\tcase strings.ContainsRune(curlArgumentOptions, c):\n\t\t\t\treturn false\n\t\t\tcase 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9', c == '#', c == ':':\n\t\t\tdefault:\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n", "\t\t\tcase c == 'o' || c == 'O':\n\t\t\tcase strings.ContainsRune(curlArgumentOptions, c):\n\t\t\t\treturn false\n\t\t\tcase 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9', c == '#', c == ':':\n\t\t\tdefault:\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn strings.ContainsAny(w[1:], \"oO\")\n\t}\n\treturn false\n}\n", "TestMayWrite|TestTestBending_AnAttachedCurl") -m("TB curl's digits, # and : end the short-flag walk", "internal/shape/shape.go", +m("TB curl's digits, # and : end the short-flag walk", "internal/shape/verb.go", "\t\t\tcase 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z', '0' <= c && c <= '9', c == '#', c == ':':\n", "\t\t\tcase 'a' <= c && c <= 'z', 'A' <= c && c <= 'Z':\n", "TestMayWrite|TestTestBending_ACurlProgressBar") -m("TB curl's argument-taking options are read as more flags", "internal/shape/shape.go", +m("TB curl's argument-taking options are read as more flags", "internal/shape/verb.go", "\t\t\tcase strings.ContainsRune(curlArgumentOptions, c):\n\t\t\t\treturn false\n", "", "TestMayWrite") -m("TB a command or process substitution is not a write", "internal/shape/shape.go", +m("TB a command or process substitution is not a write", "internal/shape/verb.go", "\t\tif opensSubstitution(toks, j) {\n", "\t\tif false && opensSubstitution(toks, j) {\n", "TestMayWrite") -m("TB backticks are not a write", "internal/shape/shape.go", +m("TB backticks are not a write", "internal/shape/verb.go", "\t\tif t.ticks > 0 || t.opaque && holdsSubstitution(t.text) {\n", "\t\tif t.opaque && holdsSubstitution(t.text) {\n", "TestMayWrite") -m("TB a quoted $( is not a write", "internal/shape/shape.go", +m("TB a quoted $( is not a write", "internal/shape/verb.go", "\t\tif t.ticks > 0 || t.opaque && holdsSubstitution(t.text) {\n", "\t\tif t.ticks > 0 {\n", "TestMayWrite") -m("TB a single-quoted $( is a write", "internal/shape/shape.go", +m("TB a single-quoted $( is a write", "internal/shape/verb.go", "\t\tif t.ticks > 0 || t.opaque && holdsSubstitution(t.text) {\n", "\t\tif t.ticks > 0 || holdsSubstitution(t.text) {\n", "TestMayWrite") -m("TB arithmetic is a command substitution", "internal/shape/shape.go", +m("TB arithmetic is a command substitution", "internal/shape/verb.go", "\treturn strings.HasSuffix(t.text, \"$\") && !(gluedParen(toks, j+2) && arithmeticAt(parenText(toks[j+3:])))\n", "\treturn strings.HasSuffix(t.text, \"$\")\n", "TestMayWrite") -m("TB a $(( that does not close as )) is arithmetic", "internal/shape/shape.go", +m("TB a $(( that does not close as )) is arithmetic", "internal/shape/verb.go", "\t\t\t\treturn strings.HasPrefix(s[k+1:], \")\")\n", "\t\t\t\treturn true\n", "TestMayWrite") -m("TB quoted arithmetic is a command substitution", "internal/shape/shape.go", +m("TB quoted arithmetic is a command substitution", "internal/shape/verb.go", "\t\tif !strings.HasPrefix(s[k+2:], \"(\") || !arithmeticAt(s[k+3:]) {\n", "\t\tif true {\n", "TestMayWrite") -m("TB a ( inside arithmetic opens no group", "internal/shape/shape.go", +m("TB a ( inside arithmetic opens no group", "internal/shape/verb.go", "\t\tcase '(':\n\t\t\tdepth++\n", "", "TestMayWrite") -m("TB a quoted ) inside $(( closes it", "internal/shape/shape.go", +m("TB a quoted ) inside $(( closes it", "internal/shape/verb.go", "\t\t\tb.WriteByte('w')\n", "\t\t\tb.WriteString(t.text)\n", "TestMayWrite") @@ -612,15 +612,15 @@ "\t\t\tcwd = filepath.Join(cwd, dir)\n", "\t\t\treturn cwd\n", "TestStartDirectory|TestTestBending_ALeadingRelativeCd") -m("TB a leading run stops at a step it cannot resolve", "internal/shape/shape.go", +m("TB a leading run stops at a step it cannot resolve", "internal/shape/leading.go", "\t\tif !ok {\n\t\t\treturn nil, false\n\t\t}\n\t\tdirs = append(dirs, dir)\n", "\t\tif !ok {\n\t\t\treturn dirs, len(dirs) > 0\n\t\t}\n\t\tdirs = append(dirs, dir)\n", "TestLeadingDirectory|TestTestBending_ACdBack") -m("TB a leading pushd or popd is not a directory change", "internal/shape/shape.go", +m("TB a leading pushd or popd is not a directory change", "internal/shape/leading.go", "\t\tcase \"cd\", \"pushd\", \"popd\":\n", "\t\tcase \"cd\":\n", "TestLeadingDirectory") -m("TB an assignment in front of a later cd is not a directory change", "internal/shape/shape.go", +m("TB an assignment in front of a later cd is not a directory change", "internal/shape/leading.go", "\t\tswitch toks[i+k].text {\n", "\t\t_ = k\n\t\tswitch toks[i].text {\n", "TestLeadingDirectory|TestTestBending_AnAssignedCdBack") @@ -634,19 +634,19 @@ "\t\t\t\ts.VerbClass = VerbTest\n\t\t\t\t_, runner, _ := runnerPrefix(pshaped, i, prog)\n\t\t\t\ts.Program = &runner\n", "TestTestRunnerIsRecognised|TestTestBending") -m("TB Maven's skip properties are not refused", "internal/shape/shape.go", +m("TB Maven's skip properties are not refused", "internal/shape/verb.go", " || runner == \"mvn\" && mavenSkip(t.text) {\n", " {\n", "TestTestClassRefusesWhatDoesNotRunTests|TestTestRunnerIsRecognised") -m("TB a Maven skip property refuses its =false form", "internal/shape/shape.go", +m("TB a Maven skip property refuses its =false form", "internal/shape/verb.go", "\t\treturn !hasValue || strings.EqualFold(value, \"true\")\n", "\t\treturn !hasValue || len(value) >= 0\n", "TestTestRunnerIsRecognised") -m("TB a Maven skip property is read as true only in lower case", "internal/shape/shape.go", +m("TB a Maven skip property is read as true only in lower case", "internal/shape/verb.go", "\t\treturn !hasValue || strings.EqualFold(value, \"true\")\n", "\t\treturn !hasValue || value == \"true\"\n", "TestTestRunnerIsRecognised") -m("TB a prefix entry takes isFalse's exception", "internal/shape/shape.go", +m("TB a prefix entry takes isFalse's exception", "internal/shape/verb.go", "\t\t\tif strings.HasPrefix(word, p) {\n", "\t\t\tif v, ok := strings.CutPrefix(strings.TrimPrefix(word, p), \"=\"); strings.HasPrefix(word, p) && !(ok && isFalse(v)) {\n", "TestTestRunnerIsRecognised") @@ -670,11 +670,11 @@ "\t\t\treturn s\n", "TestTestRuns_CountsAndText") -m("TB curl's : takes an argument", "internal/shape/shape.go", +m("TB curl's : takes an argument", "internal/shape/verb.go", "'0' <= c && c <= '9', c == '#', c == ':':\n", "'0' <= c && c <= '9', c == '#':\n", "TestMayWrite") -m("TB -Dmaven.test.skip runs the tests", "internal/shape/shape.go", +m("TB -Dmaven.test.skip runs the tests", "internal/shape/verb.go", "\tcase \"-DskipTests\", \"-Dmaven.test.skip\", \"-Dmaven.test.skip.exec\":\n", "\tcase \"-DskipTests\", \"-Dmaven.test.skip.exec\":\n", "TestTestClassRefusesWhatDoesNotRunTests") From 0701e8eae366651fd15cef3193ccbb65c52e767b Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 13:35:48 -0700 Subject: [PATCH 15/50] shape: split masked.go and parseSimple under the size limits masked.go was 940 code lines against the 500-line limit and parseSimple 118 against the 100-line one (Law 10). This branch changed statusMasked's signature, so the file is held to the limit here rather than left for later. parseSimple hands two self-contained steps to functions of their own, each moved without changing a line of its body: runsShellLine, the check for a shell's -c anywhere on the command, and runner, which marks a command as a test or build run in the group of runs with the same words. parseSimple is 85 code lines. The file is split by stage, moved without a change to any line: masked.go the kinds, the runner lists, statusMasked, the AST (147) masked_parse.go the grammar: lists, and-ors, pipelines, compounds (303) masked_simple.go a simple command: builtins, assignments, values (245) masked_eval.go the runner digest and the abstract run (262) 37 MS mutants name the file their anchor moved to, and the three anchored in parseSimple's moved steps still find their text. --- internal/shape/masked.go | 927 ----------------------------- internal/shape/masked_eval.go | 317 ++++++++++ internal/shape/masked_parse.go | 345 +++++++++++ internal/shape/masked_simple.go | 289 +++++++++ test/mutation/mutants/MS_masked.py | 74 +-- 5 files changed, 988 insertions(+), 964 deletions(-) create mode 100644 internal/shape/masked_eval.go create mode 100644 internal/shape/masked_parse.go create mode 100644 internal/shape/masked_simple.go diff --git a/internal/shape/masked.go b/internal/shape/masked.go index 434458e8..9b50e1e3 100644 --- a/internal/shape/masked.go +++ b/internal/shape/masked.go @@ -1,11 +1,5 @@ package shape -import ( - "path" - "strconv" - "strings" -) - // Masked kinds: what shape.status_masked says of a shell line. A closed set, // like the verb classes, and decided against fixed lists whose words are // compared and dropped. @@ -255,924 +249,3 @@ type lineParser struct { assigned map[string]bool // names an assignment on the line set errexit bool // a set on the line turns errexit on } - -// parseLine parses the whole line. False where any part of it is not read: -// the line is then null. -func (p *lineParser) parseLine() (list, bool) { - l, ok := p.parseList() - return l, ok && p.pos == len(p.toks) -} - -// word reports an unquoted word at j that is w: a reserved word, in command -// position. -func (p *lineParser) word(j int, w string) bool { - if j >= len(p.toks) { - return false - } - t := p.toks[j] - return !t.meta && t.quotedAt < 0 && !t.opaque && t.text == w -} - -func (p *lineParser) op(j int, op string) bool { - return j < len(p.toks) && p.toks[j].meta && p.toks[j].text == op -} - -// atTerminator reports the end of the line, or a token that ends the list -// being read: a subshell's `)`, or a reserved word that closes or continues -// a compound command. -func (p *lineParser) atTerminator() bool { - if p.pos >= len(p.toks) || p.op(p.pos, ")") { - return true - } - for _, w := range []string{"}", "then", "elif", "else", "fi", "do", "done", "esac"} { - if p.word(p.pos, w) { - return true - } - } - return false -} - -func (p *lineParser) parseList() (list, bool) { - var l list - for !p.atTerminator() { - ao, ok := p.parseAndOr() - if !ok { - return l, false - } - it := listItem{ao: ao} - switch { - case p.op(p.pos, ";"): - p.pos++ - case p.op(p.pos, "&"): - it.bg = true - p.pos++ - case p.pos < len(p.toks) && p.toks[p.pos].nlBefore, p.atTerminator(): - // A newline, or the end of the list. - default: - return l, false - } - l.items = append(l.items, it) - } - return l, true -} - -// parseBody is parseList for the inside of a compound command, which may not -// be empty. -func (p *lineParser) parseBody() (list, bool) { - l, ok := p.parseList() - return l, ok && len(l.items) > 0 -} - -func (p *lineParser) parseAndOr() (andOr, bool) { - var ao andOr - for { - pl, ok := p.parsePipeline() - if !ok { - return ao, false - } - ao.pipes = append(ao.pipes, pl) - if p.pos >= len(p.toks) || p.toks[p.pos].nlBefore || !p.op(p.pos, "&&") && !p.op(p.pos, "||") { - return ao, true - } - ao.ops = append(ao.ops, p.toks[p.pos].text) - p.pos++ - } -} - -func (p *lineParser) parsePipeline() (pipeline, bool) { - var pl pipeline - if p.word(p.pos, "!") { - pl.neg = true - p.pos++ - } - for { - c, ok := p.parseCommand() - if !ok { - return pl, false - } - pl.stages = append(pl.stages, c) - if !p.op(p.pos, "|") || p.toks[p.pos].nlBefore { - return pl, true - } - p.pos++ - if p.op(p.pos, "&") && p.toks[p.pos].glued { - // |&, which pipes stderr as well: a pipe. - p.pos++ - } - } -} - -func (p *lineParser) parseCommand() (command, bool) { - if p.pos >= len(p.toks) { - return nil, false - } - t := p.toks[p.pos] - switch { - case t.meta && t.text == "(": - if p.op(p.pos+1, "(") && p.toks[p.pos+1].glued { - // ((, arithmetic: not read. - return nil, false - } - p.pos++ - body, ok := p.parseBody() - if !ok || !p.op(p.pos, ")") { - return nil, false - } - p.pos++ - return &subshell{body}, p.redirections() - case t.meta: - return nil, false - case p.word(p.pos, "{"): - p.pos++ - body, ok := p.parseBody() - if !ok || !p.word(p.pos, "}") { - return nil, false - } - p.pos++ - return &group{body}, p.redirections() - case p.word(p.pos, "if"): - return p.parseIf() - case p.word(p.pos, "while"), p.word(p.pos, "until"): - from := len(p.simples) - p.pos++ - if _, ok := p.parseBody(); !ok || !p.word(p.pos, "do") { - return nil, false - } - return p.parseLoopBody(from) - case p.word(p.pos, "for"): - from := len(p.simples) - p.pos++ - if p.pos >= len(p.toks) || p.toks[p.pos].meta || !isName(p.toks[p.pos].text) { - // for ((...)), or no name. - return nil, false - } - p.pos++ - if p.word(p.pos, "in") && !p.toks[p.pos].nlBefore { - for p.pos++; p.pos < len(p.toks) && !p.toks[p.pos].nlBefore && !p.op(p.pos, ";"); p.pos++ { - if p.toks[p.pos].meta { - return nil, false - } - } - } - if p.op(p.pos, ";") { - p.pos++ - } - if !p.word(p.pos, "do") { - return nil, false - } - return p.parseLoopBody(from) - case p.word(p.pos, "[["): - end := p.pos + 1 - for ; end < len(p.toks) && !p.word(end, "]]"); end++ { - } - if end >= len(p.toks) { - return nil, false - } - s := &simple{start: p.pos, end: end + 1, op: opOther, runner: -1} - if p.readsStatus(p.pos+1, end) { - s.op = opStatusRead - } - p.simples = append(p.simples, s) - p.pos = end + 1 - return s, true - } - for _, w := range []string{"case", "select", "function", "coproc", "then", "elif", "else", "fi", "do", "done", "esac", "}", "in", "]]"} { - if p.word(p.pos, w) { - return nil, false - } - } - return p.parseSimple() -} - -func (p *lineParser) parseIf() (command, bool) { - c := &ifCmd{} - p.pos++ // if - for { - cond, ok := p.parseBody() - if !ok || !p.word(p.pos, "then") { - return nil, false - } - p.pos++ - body, ok := p.parseBody() - if !ok { - return nil, false - } - c.conds, c.bodies = append(c.conds, cond), append(c.bodies, body) - if p.word(p.pos, "elif") { - p.pos++ - continue - } - break - } - if p.word(p.pos, "else") { - p.pos++ - els, ok := p.parseBody() - if !ok { - return nil, false - } - c.els = &els - } - if !p.word(p.pos, "fi") { - return nil, false - } - p.pos++ - return c, p.redirections() -} - -// parseLoopBody reads a loop's `do ... done`, from the `do`; from is the -// first simple command of the loop, for whether it is unsure. -func (p *lineParser) parseLoopBody(from int) (command, bool) { - p.pos++ // do - if _, ok := p.parseBody(); !ok || !p.word(p.pos, "done") { - return nil, false - } - p.pos++ - l := &loop{} - for _, s := range p.simples[from:] { - switch s.op { - case opRunner, opExit, opStatusRead, opSet, opAssign, opCd: - l.unsure = true - } - } - return l, p.redirections() -} - -// redirections reads the redirections after a compound command. -func (p *lineParser) redirections() bool { - for p.pos < len(p.toks) && !p.toks[p.pos].nlBefore { - j := p.pos - if t := p.toks[j]; !t.meta && isFDPrefix(t) && j+1 < len(p.toks) && p.toks[j+1].meta && p.toks[j+1].glued && isRedirect(p.toks[j+1].text) { - j++ - } - if p.op(j, "&") && j+1 < len(p.toks) && p.toks[j+1].meta && p.toks[j+1].glued && strings.HasPrefix(p.toks[j+1].text, ">") { - j++ - } - if !p.toks[j].meta || !isRedirect(p.toks[j].text) { - return true - } - end := operatorEnd(p.toks, j) - if noTarget(p.toks, end) || p.toks[end+1].meta { - return false - } - p.pos = end + 2 - } - return true -} - -// simpleEnd returns the index of the token after the simple command that -// begins at s: a separator, a pipe, a subshell's `)`, the first token of the -// next line, or the end. A group inside a word -- $( ), <( ), backticks -- -// is read past to its close. False where the end cannot be told: a group -// that never closes or whose depth the tokens do not show, an empty `()` -// (a function), a ${ the word does not close, or a redirection with no -// target. -func (p *lineParser) simpleEnd(s int) (int, bool) { - toks := p.toks - var open []byte - for j := s; j < len(toks); j++ { - t := toks[j] - if j > s && t.nlBefore && len(open) == 0 { - return j, true - } - if openBrace(t) { - return 0, false - } - if len(open) > 0 { - if unknownDepth(toks, j, false) { - return 0, false - } - open = inGroup(open, t) - continue - } - switch { - case t.ticks%2 == 1: - open = append(open, '`') - case !t.meta: - case t.text == "(": - if j+1 < len(toks) && toks[j+1].meta && toks[j+1].text == ")" { - return 0, false - } - open = append(open, '(') - case t.text == ")": - // A subshell's close: the command ends before it. - return j, true - case t.text == "&" && j+1 < len(toks) && toks[j+1].meta && toks[j+1].glued && strings.HasPrefix(toks[j+1].text, ">"): - // &> and &>>: a redirection, not the background separator. - j++ - fallthrough - case isRedirect(t.text): - end := operatorEnd(toks, j) - if noTarget(toks, end) { - return 0, false - } - j = end - case t.text == ";" || t.text == "&&" || t.text == "||" || t.text == "|" || t.text == "&": - return j, true - } - } - if len(open) > 0 { - return 0, false - } - return len(toks), true -} - -// words returns the indices of the tokens of the command [s, e) that are -// neither a redirection, its target nor an fd written against it. -func (p *lineParser) words(s, e int) []int { - toks := p.toks - var out []int - for j := s; j < e; j++ { - t := toks[j] - switch { - case !t.meta && j+1 < e && toks[j+1].meta && toks[j+1].glued && isRedirect(toks[j+1].text) && isFDPrefix(t): - continue - case t.meta && t.text == "&" && j+1 < e && toks[j+1].meta && toks[j+1].glued && strings.HasPrefix(toks[j+1].text, ">"): - j++ - fallthrough - case t.meta && isRedirect(toks[j].text): - j = operatorEnd(toks, j) + 1 - continue - } - out = append(out, j) - } - return out -} - -// shells are the programs whose -c runs a line of its own, which is not -// looked into. -var shells = map[string]bool{"sh": true, "bash": true, "zsh": true, "dash": true, "ksh": true, "mksh": true} - -func (p *lineParser) parseSimple() (command, bool) { - s := p.pos - e, ok := p.simpleEnd(s) - if !ok || e == s { - return nil, false - } - p.pos = e - c := &simple{start: s, end: e, runner: -1} - p.simples = append(p.simples, c) - toks := p.toks - ws := p.words(s, e) - if len(ws) == 0 { - // Redirections only. - return c, true - } - - if assigns, ok := p.assignments(ws); ok { - c.op, c.assigns = opAssign, assigns - return c, true - } - - k, ok := programToken(toks[s:e], false) - if !ok { - return nil, false - } - i := s + k - prog := path.Base(toks[i].text) - var args []int - for _, j := range ws { - if j > i { - args = append(args, j) - } - } - // A shell's -c anywhere on the command: `bash -c`, `sudo sh -lc`. - for n, j := range ws { - if t := toks[j]; j < i || t.meta || t.opaque || !shells[path.Base(t.text)] { - continue - } - for _, a := range ws[n+1:] { - if w := toks[a].text; len(w) > 1 && w[0] == '-' && w[1] != '-' && strings.ContainsRune(w, 'c') { - return nil, false - } - } - } - if toks[i].quotedAt < 0 { - switch prog { - case "eval", "trap", "return", "exec", "function", "coproc", "select", "case", "alias": - // What they run, or what they make the line do, is not on it. - return nil, false - case "set": - opts, ok := setOptions(toks, args) - if !ok { - return nil, false - } - c.op, c.opts = opSet, opts - for _, o := range opts { - if o.errexit && o.on { - p.errexit = true - } - } - return c, true - case "exit": - c.op = opExit - for _, j := range args { - c.args = append(c.args, p.valueOf(toks[j])) - } - return c, true - case "true", ":": - c.op = opTrue - return c, true - case "false": - c.op = opFalse - return c, true - case "[", "test", "let", "expr": - if p.readsStatus(i+1, e) { - c.op = opStatusRead - } - return c, true - case "wait": - if len(args) > 0 { - c.op = opStatusRead - } - return c, true - case "cd", "pushd", "popd": - c.op = opCd - return c, true - case "export", "local", "declare", "typeset", "readonly": - c.op = opAssign - for _, j := range args { - if t := toks[j]; !t.meta && isShellAssignment(t) { - name, v := splitAssignment(t.text) - p.assigned[name] = true - c.assigns = append(c.assigns, assignment{name, p.valueOf(token{text: v, quotedAt: -1})}) - } - } - return c, true - } - } - - ri, name, ok := runnerPrefix(toks[:e], i, prog) - if !ok { - return c, true - } - var kind string - switch { - case runnerOn(toks, ri, name, e, testCommands): - kind = MaskedTest - case runnerOn(toks, ri, name, e, buildCommands): - kind = MaskedBuild - default: - return c, true - } - key := p.runnerWords(ri, e) - g, seen := p.groups[key] - if !seen { - g = len(p.runners) - p.groups[key] = g - p.runners = append(p.runners, runnerGroup{key: key, kind: kind}) - } - p.runners[g].starts = append(p.runners[g].starts, s) - p.runners[g].last = s - c.op, c.runner = opRunner, g - return c, true -} - -// assignments reads a command of assignments only, `rc=$?` and the like. -// False when a word is not an assignment, or is one whose value holds an -// expansion this tokenizer does not parse and is not one value reads. -func (p *lineParser) assignments(ws []int) ([]assignment, bool) { - var out []assignment - for _, j := range ws { - t := p.toks[j] - if t.meta || !isShellAssignment(t) { - return nil, false - } - name, v := splitAssignment(t.text) - val := p.valueOf(token{text: v, quotedAt: -1}) - if t.opaque && val.kind == valUnknown { - return nil, false - } - out = append(out, assignment{name, val}) - } - for _, a := range out { - p.assigned[a.name] = true - } - return out, true -} - -// splitAssignment splits NAME=value; a subscript or += leaves a name no -// reader looks up. -func splitAssignment(w string) (name, val string) { - eq := strings.IndexByte(w, '=') - return w[:eq], w[eq+1:] -} - -// isName reports a shell variable name. -func isName(s string) bool { - if s == "" { - return false - } - for j := 0; j < len(s); j++ { - c := s[j] - if !(c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c == '_' || c >= '0' && c <= '9' && j > 0) { - return false - } - } - return true -} - -// valueOf reads a word as a value: digits (or a sign and digits), $?, or a -// variable, $NAME or ${NAME}. PIPESTATUS is not read: zsh has none, so its -// value is unknown. -func (p *lineParser) valueOf(t token) value { - w := t.text - if t.meta { - return value{} - } - switch { - case w == "$?": - return value{kind: valStatus} - case strings.HasPrefix(w, "${") && strings.HasSuffix(w, "}") && isName(w[2:len(w)-1]): - return value{kind: valVar, name: w[2 : len(w)-1]} - case strings.HasPrefix(w, "$") && isName(w[1:]): - return value{kind: valVar, name: w[1:]} - } - digits := strings.TrimPrefix(w, "-") - if digits == "" || strings.Trim(digits, "0123456789") != "" || len(digits) > 18 { - return value{} - } - n, err := strconv.ParseInt(w, 10, 64) - if err != nil { - return value{} - } - return value{kind: valLiteral, n: int(((n % 256) + 256) % 256)} -} - -// readsStatus reports a word of [s, e) that reads a status: $?, PIPESTATUS, -// or a variable an assignment on the line set. -func (p *lineParser) readsStatus(s, e int) bool { - for j := s; j < e; j++ { - w := p.toks[j].text - if strings.Contains(w, "$?") || strings.Contains(w, "PIPESTATUS") { - return true - } - for name := range p.assigned { - if strings.Contains(w, "$"+name) || strings.Contains(w, "${"+name) { - return true - } - } - } - return false -} - -// setOptions reads a `set` command's arguments for the two options that -// change whether a status reaches the line's: errexit (`-e`, in any cluster -// of short options such as -euo, or `-o errexit`) and pipefail (`-o -// pipefail`), and their `+` forms, which turn them off. A cluster holding o -// takes the next word as the option's name. False on a word this tokenizer -// cannot vouch for, and on a cluster where o is not last, which bash and zsh -// read differently. -func setOptions(toks []token, args []int) ([]setOpt, bool) { - var out []setOpt - for n := 0; n < len(args); n++ { - t := toks[args[n]] - if t.meta || t.opaque { - return nil, false - } - w := t.text - if w == "--" || w == "-" { - break - } - if len(w) < 2 || w[0] != '-' && w[0] != '+' { - continue - } - on := w[0] == '-' - for k, c := range w[1:] { - switch c { - case 'e': - out = append(out, setOpt{errexit: true, on: on}) - case 'o': - if k < len(w)-2 { - // o before the cluster's end, `-opipefail`: bash rejects - // the word and zsh reads the rest as the name. - return nil, false - } - if n+1 >= len(args) { - continue - } - n++ - switch toks[args[n]].text { - case "pipefail": - out = append(out, setOpt{errexit: false, on: on}) - case "errexit": - out = append(out, setOpt{errexit: true, on: on}) - } - } - } - } - return out, true -} - -// runnerWords is what runnerDigest digests: the words of the runner whose -// program is the token at i, to the end of its command, with its -// redirections left out, so that `make test 2>&1 | tail` and a later plain -// `make test` are the same runner. A word glued to the one before it, an -// operator inside a word and a word this tokenizer split where the shell may -// not are marked, so that only the same tokens are the same runner. -func (p *lineParser) runnerWords(i, end int) string { - var words []string - for _, j := range p.words(i, end) { - t := p.toks[j] - w := t.text - if t.meta { - w = "\x01" + w - } - if t.opaque { - w = "\x02" + w - } - if t.glued && j > i { - w = "\x03" + w - } - words = append(words, w) - } - return strings.Join(words, "\x00") -} - -// runnerDigest is the keyed digest of runner group g's words. Like the shape -// digest, compared and never printed; the domain is a name no tool has. Nil -// when a cd the start directory does not hold ran before it: where it ran is -// not known, so it neither is nor gets a follow-up. -func (p *lineParser) runnerDigest(g int, key []byte) *string { - if p.runners[g].displaced { - return nil - } - d := digest(key, "\x00runner", p.runners[g].key) - return &d -} - -// displace marks each runner that a cd, pushd or popd ran before on the -// line, other than the leading plain `cd DIR &&` steps the call's start -// directory folds in (folded of them, three tokens each). -func (p *lineParser) displace(folded int) { - for _, s := range p.simples { - if s.op != opCd || s.start < 3*folded && s.start%3 == 0 { - continue - } - for g := range p.runners { - for _, at := range p.runners[g].starts { - if s.start < at { - p.runners[g].displaced = true - } - } - } - } -} - -// evalState is the abstract run's state: the last status (-1 not known), -// the variables an assignment set, and the two options. -type evalState struct { - status int - vars map[string]int - errexit, pipefail bool - // exited: an exit, or errexit, ended the line (or its subshell). lost: - // the run cannot go on, a status it branches on not being known. - exited, lost bool -} - -func (st *evalState) copy() *evalState { - c := *st - c.vars = make(map[string]int, len(st.vars)) - for k, v := range st.vars { - c.vars[k] = v - } - c.exited = false - return &c -} - -type evaluator struct { - p *lineParser - target int - fail bool - zsh bool // errexit never fires, as in zsh under Claude Code's wrapper - ran bool -} - -// runShells runs the line with runner group g failing and then passing, as -// bash runs it and, where a set on the line turns errexit on, again as zsh -// runs it inside Claude Code's `eval '' && ...` wrapper, where errexit -// never fires. It returns the two exit statuses and whether the runner ran; -// -1 for both where either run cannot tell, or where the two shells disagree -// on whether the runner's status reaches the line's: the record does not say -// which shell ran it. -func (p *lineParser) runShells(l list, g int) (f, pass int, ran bool) { - f, ran = p.run(l, g, true, false) - // Whether the runner is reached depends only on what runs before it, the - // same in both runs, so ran comes from the first. - pass, _ = p.run(l, g, false, false) - if !p.errexit { - // Without errexit the two shells run it alike. - return f, pass, ran - } - zf, zran := p.run(l, g, true, true) - // As above: zran comes from the first of the two zsh runs. - zpass, _ := p.run(l, g, false, true) - if zf < 0 || zpass < 0 || (f == 0 || f == pass) != (zf == 0 || zf == zpass) { - return -1, -1, ran && zran - } - return f, pass, ran && zran -} - -// run runs the line with runner group g failing (fail) or passing, every -// other recognised runner passing, errexit never firing where zsh, and -// returns its exit status, -1 when it cannot tell, and whether the runner -// ran. -func (p *lineParser) run(l list, g int, fail, zsh bool) (int, bool) { - ev := &evaluator{p: p, target: g, fail: fail, zsh: zsh} - st := &evalState{vars: map[string]int{}} - ev.list(st, l, false) - if st.lost { - return -1, ev.ran - } - return st.status, ev.ran -} - -// list runs l. noErr: errexit is ignored here. -func (ev *evaluator) list(st *evalState, l list, noErr bool) { - for _, it := range l.items { - if st.exited || st.lost { - return - } - if it.bg { - // Its status is no one's: the line goes on with 0. - ev.andOr(st.copy(), it.ao, true) - st.status = 0 - continue - } - ev.andOr(st, it.ao, noErr) - } -} - -func (ev *evaluator) andOr(st *evalState, ao andOr, noErr bool) { - for k, pl := range ao.pipes { - if k > 0 { - if st.status < 0 { - st.lost = true - return - } - if ao.ops[k-1] == "&&" && st.status != 0 || ao.ops[k-1] == "||" && st.status == 0 { - continue - } - } - // errexit ignores every pipeline of the list but the last, and a - // negated one. - ignored := noErr || k < len(ao.pipes)-1 || pl.neg - ev.pipeline(st, pl, ignored) - if st.exited || st.lost { - return - } - if st.errexit && !ignored && !ev.zsh && st.status != 0 { - if st.status < 0 { - st.lost = true - return - } - st.exited = true - return - } - } -} - -func (ev *evaluator) pipeline(st *evalState, pl pipeline, noErr bool) { - if len(pl.stages) == 1 { - ev.command(st, pl.stages[0], noErr) - if st.exited || st.lost { - return - } - } else { - // Each stage is a subshell; the status is the last stage's, or under - // pipefail the last that failed. - statuses := make([]int, len(pl.stages)) - for n, c := range pl.stages { - sub := st.copy() - ev.command(sub, c, noErr) - if sub.lost { - st.lost = true - return - } - statuses[n] = sub.status - } - s := statuses[len(statuses)-1] - if st.pipefail { - s = 0 - for _, x := range statuses { - if x != 0 { - s = x - } - } - } - for _, x := range statuses { - if x < 0 && (st.pipefail || s < 0) { - s = -1 - } - } - st.status = s - } - if pl.neg && st.status >= 0 { - if st.status == 0 { - st.status = 1 - } else { - st.status = 0 - } - } -} - -func (ev *evaluator) command(st *evalState, c command, noErr bool) { - switch c := c.(type) { - case *simple: - ev.simple(st, c) - case *subshell: - sub := st.copy() - ev.list(sub, c.body, noErr) - if sub.lost { - st.lost = true - return - } - st.status = sub.status - case *group: - ev.list(st, c.body, noErr) - case *ifCmd: - for k, cond := range c.conds { - ev.list(st, cond, true) - if st.exited || st.lost { - return - } - if st.status < 0 { - st.lost = true - return - } - if st.status == 0 { - ev.list(st, c.bodies[k], noErr) - return - } - } - if c.els != nil { - ev.list(st, *c.els, noErr) - return - } - st.status = 0 - case *loop: - if c.unsure { - st.lost = true - return - } - st.status = 0 - } -} - -func (ev *evaluator) simple(st *evalState, c *simple) { - switch c.op { - case opFalse: - st.status = 1 - case opStatusRead: - st.status = -1 - case opAssign: - for _, a := range c.assigns { - st.vars[a.name] = ev.value(st, a.val) - } - st.status = 0 - case opSet: - for _, o := range c.opts { - if o.errexit { - st.errexit = o.on - } else { - st.pipefail = o.on - } - } - st.status = 0 - case opExit: - switch len(c.args) { - case 0: - case 1: - st.status = ev.value(st, c.args[0]) - default: - // Too many arguments: bash does not exit. - st.lost = true - return - } - st.exited = true - case opRunner: - st.status = 0 - if c.runner == ev.target { - ev.ran = true - if ev.fail { - st.status = 1 - } - } - default: - st.status = 0 - } -} - -// value is v's number in st, -1 when it is not known. -func (ev *evaluator) value(st *evalState, v value) int { - switch v.kind { - case valLiteral: - return v.n - case valStatus: - return st.status - case valVar: - if n, ok := st.vars[v.name]; ok { - return n - } - } - return -1 -} diff --git a/internal/shape/masked_eval.go b/internal/shape/masked_eval.go new file mode 100644 index 00000000..f810429c --- /dev/null +++ b/internal/shape/masked_eval.go @@ -0,0 +1,317 @@ +package shape + +import "strings" + +// runnerWords is what runnerDigest digests: the words of the runner whose +// program is the token at i, to the end of its command, with its +// redirections left out, so that `make test 2>&1 | tail` and a later plain +// `make test` are the same runner. A word glued to the one before it, an +// operator inside a word and a word this tokenizer split where the shell may +// not are marked, so that only the same tokens are the same runner. +func (p *lineParser) runnerWords(i, end int) string { + var words []string + for _, j := range p.words(i, end) { + t := p.toks[j] + w := t.text + if t.meta { + w = "\x01" + w + } + if t.opaque { + w = "\x02" + w + } + if t.glued && j > i { + w = "\x03" + w + } + words = append(words, w) + } + return strings.Join(words, "\x00") +} + +// runnerDigest is the keyed digest of runner group g's words. Like the shape +// digest, compared and never printed; the domain is a name no tool has. Nil +// when a cd the start directory does not hold ran before it: where it ran is +// not known, so it neither is nor gets a follow-up. +func (p *lineParser) runnerDigest(g int, key []byte) *string { + if p.runners[g].displaced { + return nil + } + d := digest(key, "\x00runner", p.runners[g].key) + return &d +} + +// displace marks each runner that a cd, pushd or popd ran before on the +// line, other than the leading plain `cd DIR &&` steps the call's start +// directory folds in (folded of them, three tokens each). +func (p *lineParser) displace(folded int) { + for _, s := range p.simples { + if s.op != opCd || s.start < 3*folded && s.start%3 == 0 { + continue + } + for g := range p.runners { + for _, at := range p.runners[g].starts { + if s.start < at { + p.runners[g].displaced = true + } + } + } + } +} + +// evalState is the abstract run's state: the last status (-1 not known), +// the variables an assignment set, and the two options. +type evalState struct { + status int + vars map[string]int + errexit, pipefail bool + // exited: an exit, or errexit, ended the line (or its subshell). lost: + // the run cannot go on, a status it branches on not being known. + exited, lost bool +} + +func (st *evalState) copy() *evalState { + c := *st + c.vars = make(map[string]int, len(st.vars)) + for k, v := range st.vars { + c.vars[k] = v + } + c.exited = false + return &c +} + +type evaluator struct { + p *lineParser + target int + fail bool + zsh bool // errexit never fires, as in zsh under Claude Code's wrapper + ran bool +} + +// runShells runs the line with runner group g failing and then passing, as +// bash runs it and, where a set on the line turns errexit on, again as zsh +// runs it inside Claude Code's `eval '' && ...` wrapper, where errexit +// never fires. It returns the two exit statuses and whether the runner ran; +// -1 for both where either run cannot tell, or where the two shells disagree +// on whether the runner's status reaches the line's: the record does not say +// which shell ran it. +func (p *lineParser) runShells(l list, g int) (f, pass int, ran bool) { + f, ran = p.run(l, g, true, false) + // Whether the runner is reached depends only on what runs before it, the + // same in both runs, so ran comes from the first. + pass, _ = p.run(l, g, false, false) + if !p.errexit { + // Without errexit the two shells run it alike. + return f, pass, ran + } + zf, zran := p.run(l, g, true, true) + // As above: zran comes from the first of the two zsh runs. + zpass, _ := p.run(l, g, false, true) + if zf < 0 || zpass < 0 || (f == 0 || f == pass) != (zf == 0 || zf == zpass) { + return -1, -1, ran && zran + } + return f, pass, ran && zran +} + +// run runs the line with runner group g failing (fail) or passing, every +// other recognised runner passing, errexit never firing where zsh, and +// returns its exit status, -1 when it cannot tell, and whether the runner +// ran. +func (p *lineParser) run(l list, g int, fail, zsh bool) (int, bool) { + ev := &evaluator{p: p, target: g, fail: fail, zsh: zsh} + st := &evalState{vars: map[string]int{}} + ev.list(st, l, false) + if st.lost { + return -1, ev.ran + } + return st.status, ev.ran +} + +// list runs l. noErr: errexit is ignored here. +func (ev *evaluator) list(st *evalState, l list, noErr bool) { + for _, it := range l.items { + if st.exited || st.lost { + return + } + if it.bg { + // Its status is no one's: the line goes on with 0. + ev.andOr(st.copy(), it.ao, true) + st.status = 0 + continue + } + ev.andOr(st, it.ao, noErr) + } +} + +func (ev *evaluator) andOr(st *evalState, ao andOr, noErr bool) { + for k, pl := range ao.pipes { + if k > 0 { + if st.status < 0 { + st.lost = true + return + } + if ao.ops[k-1] == "&&" && st.status != 0 || ao.ops[k-1] == "||" && st.status == 0 { + continue + } + } + // errexit ignores every pipeline of the list but the last, and a + // negated one. + ignored := noErr || k < len(ao.pipes)-1 || pl.neg + ev.pipeline(st, pl, ignored) + if st.exited || st.lost { + return + } + if st.errexit && !ignored && !ev.zsh && st.status != 0 { + if st.status < 0 { + st.lost = true + return + } + st.exited = true + return + } + } +} + +func (ev *evaluator) pipeline(st *evalState, pl pipeline, noErr bool) { + if len(pl.stages) == 1 { + ev.command(st, pl.stages[0], noErr) + if st.exited || st.lost { + return + } + } else { + // Each stage is a subshell; the status is the last stage's, or under + // pipefail the last that failed. + statuses := make([]int, len(pl.stages)) + for n, c := range pl.stages { + sub := st.copy() + ev.command(sub, c, noErr) + if sub.lost { + st.lost = true + return + } + statuses[n] = sub.status + } + s := statuses[len(statuses)-1] + if st.pipefail { + s = 0 + for _, x := range statuses { + if x != 0 { + s = x + } + } + } + for _, x := range statuses { + if x < 0 && (st.pipefail || s < 0) { + s = -1 + } + } + st.status = s + } + if pl.neg && st.status >= 0 { + if st.status == 0 { + st.status = 1 + } else { + st.status = 0 + } + } +} + +func (ev *evaluator) command(st *evalState, c command, noErr bool) { + switch c := c.(type) { + case *simple: + ev.simple(st, c) + case *subshell: + sub := st.copy() + ev.list(sub, c.body, noErr) + if sub.lost { + st.lost = true + return + } + st.status = sub.status + case *group: + ev.list(st, c.body, noErr) + case *ifCmd: + for k, cond := range c.conds { + ev.list(st, cond, true) + if st.exited || st.lost { + return + } + if st.status < 0 { + st.lost = true + return + } + if st.status == 0 { + ev.list(st, c.bodies[k], noErr) + return + } + } + if c.els != nil { + ev.list(st, *c.els, noErr) + return + } + st.status = 0 + case *loop: + if c.unsure { + st.lost = true + return + } + st.status = 0 + } +} + +func (ev *evaluator) simple(st *evalState, c *simple) { + switch c.op { + case opFalse: + st.status = 1 + case opStatusRead: + st.status = -1 + case opAssign: + for _, a := range c.assigns { + st.vars[a.name] = ev.value(st, a.val) + } + st.status = 0 + case opSet: + for _, o := range c.opts { + if o.errexit { + st.errexit = o.on + } else { + st.pipefail = o.on + } + } + st.status = 0 + case opExit: + switch len(c.args) { + case 0: + case 1: + st.status = ev.value(st, c.args[0]) + default: + // Too many arguments: bash does not exit. + st.lost = true + return + } + st.exited = true + case opRunner: + st.status = 0 + if c.runner == ev.target { + ev.ran = true + if ev.fail { + st.status = 1 + } + } + default: + st.status = 0 + } +} + +// value is v's number in st, -1 when it is not known. +func (ev *evaluator) value(st *evalState, v value) int { + switch v.kind { + case valLiteral: + return v.n + case valStatus: + return st.status + case valVar: + if n, ok := st.vars[v.name]; ok { + return n + } + } + return -1 +} diff --git a/internal/shape/masked_parse.go b/internal/shape/masked_parse.go new file mode 100644 index 00000000..bef80ff4 --- /dev/null +++ b/internal/shape/masked_parse.go @@ -0,0 +1,345 @@ +package shape + +import "strings" + +// parseLine parses the whole line. False where any part of it is not read: +// the line is then null. +func (p *lineParser) parseLine() (list, bool) { + l, ok := p.parseList() + return l, ok && p.pos == len(p.toks) +} + +// word reports an unquoted word at j that is w: a reserved word, in command +// position. +func (p *lineParser) word(j int, w string) bool { + if j >= len(p.toks) { + return false + } + t := p.toks[j] + return !t.meta && t.quotedAt < 0 && !t.opaque && t.text == w +} + +func (p *lineParser) op(j int, op string) bool { + return j < len(p.toks) && p.toks[j].meta && p.toks[j].text == op +} + +// atTerminator reports the end of the line, or a token that ends the list +// being read: a subshell's `)`, or a reserved word that closes or continues +// a compound command. +func (p *lineParser) atTerminator() bool { + if p.pos >= len(p.toks) || p.op(p.pos, ")") { + return true + } + for _, w := range []string{"}", "then", "elif", "else", "fi", "do", "done", "esac"} { + if p.word(p.pos, w) { + return true + } + } + return false +} + +func (p *lineParser) parseList() (list, bool) { + var l list + for !p.atTerminator() { + ao, ok := p.parseAndOr() + if !ok { + return l, false + } + it := listItem{ao: ao} + switch { + case p.op(p.pos, ";"): + p.pos++ + case p.op(p.pos, "&"): + it.bg = true + p.pos++ + case p.pos < len(p.toks) && p.toks[p.pos].nlBefore, p.atTerminator(): + // A newline, or the end of the list. + default: + return l, false + } + l.items = append(l.items, it) + } + return l, true +} + +// parseBody is parseList for the inside of a compound command, which may not +// be empty. +func (p *lineParser) parseBody() (list, bool) { + l, ok := p.parseList() + return l, ok && len(l.items) > 0 +} + +func (p *lineParser) parseAndOr() (andOr, bool) { + var ao andOr + for { + pl, ok := p.parsePipeline() + if !ok { + return ao, false + } + ao.pipes = append(ao.pipes, pl) + if p.pos >= len(p.toks) || p.toks[p.pos].nlBefore || !p.op(p.pos, "&&") && !p.op(p.pos, "||") { + return ao, true + } + ao.ops = append(ao.ops, p.toks[p.pos].text) + p.pos++ + } +} + +func (p *lineParser) parsePipeline() (pipeline, bool) { + var pl pipeline + if p.word(p.pos, "!") { + pl.neg = true + p.pos++ + } + for { + c, ok := p.parseCommand() + if !ok { + return pl, false + } + pl.stages = append(pl.stages, c) + if !p.op(p.pos, "|") || p.toks[p.pos].nlBefore { + return pl, true + } + p.pos++ + if p.op(p.pos, "&") && p.toks[p.pos].glued { + // |&, which pipes stderr as well: a pipe. + p.pos++ + } + } +} + +func (p *lineParser) parseCommand() (command, bool) { + if p.pos >= len(p.toks) { + return nil, false + } + t := p.toks[p.pos] + switch { + case t.meta && t.text == "(": + if p.op(p.pos+1, "(") && p.toks[p.pos+1].glued { + // ((, arithmetic: not read. + return nil, false + } + p.pos++ + body, ok := p.parseBody() + if !ok || !p.op(p.pos, ")") { + return nil, false + } + p.pos++ + return &subshell{body}, p.redirections() + case t.meta: + return nil, false + case p.word(p.pos, "{"): + p.pos++ + body, ok := p.parseBody() + if !ok || !p.word(p.pos, "}") { + return nil, false + } + p.pos++ + return &group{body}, p.redirections() + case p.word(p.pos, "if"): + return p.parseIf() + case p.word(p.pos, "while"), p.word(p.pos, "until"): + from := len(p.simples) + p.pos++ + if _, ok := p.parseBody(); !ok || !p.word(p.pos, "do") { + return nil, false + } + return p.parseLoopBody(from) + case p.word(p.pos, "for"): + from := len(p.simples) + p.pos++ + if p.pos >= len(p.toks) || p.toks[p.pos].meta || !isName(p.toks[p.pos].text) { + // for ((...)), or no name. + return nil, false + } + p.pos++ + if p.word(p.pos, "in") && !p.toks[p.pos].nlBefore { + for p.pos++; p.pos < len(p.toks) && !p.toks[p.pos].nlBefore && !p.op(p.pos, ";"); p.pos++ { + if p.toks[p.pos].meta { + return nil, false + } + } + } + if p.op(p.pos, ";") { + p.pos++ + } + if !p.word(p.pos, "do") { + return nil, false + } + return p.parseLoopBody(from) + case p.word(p.pos, "[["): + end := p.pos + 1 + for ; end < len(p.toks) && !p.word(end, "]]"); end++ { + } + if end >= len(p.toks) { + return nil, false + } + s := &simple{start: p.pos, end: end + 1, op: opOther, runner: -1} + if p.readsStatus(p.pos+1, end) { + s.op = opStatusRead + } + p.simples = append(p.simples, s) + p.pos = end + 1 + return s, true + } + for _, w := range []string{"case", "select", "function", "coproc", "then", "elif", "else", "fi", "do", "done", "esac", "}", "in", "]]"} { + if p.word(p.pos, w) { + return nil, false + } + } + return p.parseSimple() +} + +func (p *lineParser) parseIf() (command, bool) { + c := &ifCmd{} + p.pos++ // if + for { + cond, ok := p.parseBody() + if !ok || !p.word(p.pos, "then") { + return nil, false + } + p.pos++ + body, ok := p.parseBody() + if !ok { + return nil, false + } + c.conds, c.bodies = append(c.conds, cond), append(c.bodies, body) + if p.word(p.pos, "elif") { + p.pos++ + continue + } + break + } + if p.word(p.pos, "else") { + p.pos++ + els, ok := p.parseBody() + if !ok { + return nil, false + } + c.els = &els + } + if !p.word(p.pos, "fi") { + return nil, false + } + p.pos++ + return c, p.redirections() +} + +// parseLoopBody reads a loop's `do ... done`, from the `do`; from is the +// first simple command of the loop, for whether it is unsure. +func (p *lineParser) parseLoopBody(from int) (command, bool) { + p.pos++ // do + if _, ok := p.parseBody(); !ok || !p.word(p.pos, "done") { + return nil, false + } + p.pos++ + l := &loop{} + for _, s := range p.simples[from:] { + switch s.op { + case opRunner, opExit, opStatusRead, opSet, opAssign, opCd: + l.unsure = true + } + } + return l, p.redirections() +} + +// redirections reads the redirections after a compound command. +func (p *lineParser) redirections() bool { + for p.pos < len(p.toks) && !p.toks[p.pos].nlBefore { + j := p.pos + if t := p.toks[j]; !t.meta && isFDPrefix(t) && j+1 < len(p.toks) && p.toks[j+1].meta && p.toks[j+1].glued && isRedirect(p.toks[j+1].text) { + j++ + } + if p.op(j, "&") && j+1 < len(p.toks) && p.toks[j+1].meta && p.toks[j+1].glued && strings.HasPrefix(p.toks[j+1].text, ">") { + j++ + } + if !p.toks[j].meta || !isRedirect(p.toks[j].text) { + return true + } + end := operatorEnd(p.toks, j) + if noTarget(p.toks, end) || p.toks[end+1].meta { + return false + } + p.pos = end + 2 + } + return true +} + +// simpleEnd returns the index of the token after the simple command that +// begins at s: a separator, a pipe, a subshell's `)`, the first token of the +// next line, or the end. A group inside a word -- $( ), <( ), backticks -- +// is read past to its close. False where the end cannot be told: a group +// that never closes or whose depth the tokens do not show, an empty `()` +// (a function), a ${ the word does not close, or a redirection with no +// target. +func (p *lineParser) simpleEnd(s int) (int, bool) { + toks := p.toks + var open []byte + for j := s; j < len(toks); j++ { + t := toks[j] + if j > s && t.nlBefore && len(open) == 0 { + return j, true + } + if openBrace(t) { + return 0, false + } + if len(open) > 0 { + if unknownDepth(toks, j, false) { + return 0, false + } + open = inGroup(open, t) + continue + } + switch { + case t.ticks%2 == 1: + open = append(open, '`') + case !t.meta: + case t.text == "(": + if j+1 < len(toks) && toks[j+1].meta && toks[j+1].text == ")" { + return 0, false + } + open = append(open, '(') + case t.text == ")": + // A subshell's close: the command ends before it. + return j, true + case t.text == "&" && j+1 < len(toks) && toks[j+1].meta && toks[j+1].glued && strings.HasPrefix(toks[j+1].text, ">"): + // &> and &>>: a redirection, not the background separator. + j++ + fallthrough + case isRedirect(t.text): + end := operatorEnd(toks, j) + if noTarget(toks, end) { + return 0, false + } + j = end + case t.text == ";" || t.text == "&&" || t.text == "||" || t.text == "|" || t.text == "&": + return j, true + } + } + if len(open) > 0 { + return 0, false + } + return len(toks), true +} + +// words returns the indices of the tokens of the command [s, e) that are +// neither a redirection, its target nor an fd written against it. +func (p *lineParser) words(s, e int) []int { + toks := p.toks + var out []int + for j := s; j < e; j++ { + t := toks[j] + switch { + case !t.meta && j+1 < e && toks[j+1].meta && toks[j+1].glued && isRedirect(toks[j+1].text) && isFDPrefix(t): + continue + case t.meta && t.text == "&" && j+1 < e && toks[j+1].meta && toks[j+1].glued && strings.HasPrefix(toks[j+1].text, ">"): + j++ + fallthrough + case t.meta && isRedirect(toks[j].text): + j = operatorEnd(toks, j) + 1 + continue + } + out = append(out, j) + } + return out +} diff --git a/internal/shape/masked_simple.go b/internal/shape/masked_simple.go new file mode 100644 index 00000000..5be3d03d --- /dev/null +++ b/internal/shape/masked_simple.go @@ -0,0 +1,289 @@ +package shape + +import ( + "path" + "strconv" + "strings" +) + +// shells are the programs whose -c runs a line of its own, which is not +// looked into. +var shells = map[string]bool{"sh": true, "bash": true, "zsh": true, "dash": true, "ksh": true, "mksh": true} + +func (p *lineParser) parseSimple() (command, bool) { + s := p.pos + e, ok := p.simpleEnd(s) + if !ok || e == s { + return nil, false + } + p.pos = e + c := &simple{start: s, end: e, runner: -1} + p.simples = append(p.simples, c) + toks := p.toks + ws := p.words(s, e) + if len(ws) == 0 { + // Redirections only. + return c, true + } + + if assigns, ok := p.assignments(ws); ok { + c.op, c.assigns = opAssign, assigns + return c, true + } + + k, ok := programToken(toks[s:e], false) + if !ok { + return nil, false + } + i := s + k + prog := path.Base(toks[i].text) + var args []int + for _, j := range ws { + if j > i { + args = append(args, j) + } + } + if runsShellLine(toks, ws, i) { + return nil, false + } + if toks[i].quotedAt < 0 { + switch prog { + case "eval", "trap", "return", "exec", "function", "coproc", "select", "case", "alias": + // What they run, or what they make the line do, is not on it. + return nil, false + case "set": + opts, ok := setOptions(toks, args) + if !ok { + return nil, false + } + c.op, c.opts = opSet, opts + for _, o := range opts { + if o.errexit && o.on { + p.errexit = true + } + } + return c, true + case "exit": + c.op = opExit + for _, j := range args { + c.args = append(c.args, p.valueOf(toks[j])) + } + return c, true + case "true", ":": + c.op = opTrue + return c, true + case "false": + c.op = opFalse + return c, true + case "[", "test", "let", "expr": + if p.readsStatus(i+1, e) { + c.op = opStatusRead + } + return c, true + case "wait": + if len(args) > 0 { + c.op = opStatusRead + } + return c, true + case "cd", "pushd", "popd": + c.op = opCd + return c, true + case "export", "local", "declare", "typeset", "readonly": + c.op = opAssign + for _, j := range args { + if t := toks[j]; !t.meta && isShellAssignment(t) { + name, v := splitAssignment(t.text) + p.assigned[name] = true + c.assigns = append(c.assigns, assignment{name, p.valueOf(token{text: v, quotedAt: -1})}) + } + } + return c, true + } + } + + return p.runner(c, s, i, e, prog) +} + +// runsShellLine reports a shell's -c anywhere on the command whose words are +// ws and whose program is toks[i] -- `bash -c`, `sudo sh -lc` -- which runs a +// line of its own that is not looked into. +func runsShellLine(toks []token, ws []int, i int) bool { + for n, j := range ws { + if t := toks[j]; j < i || t.meta || t.opaque || !shells[path.Base(t.text)] { + continue + } + for _, a := range ws[n+1:] { + if w := toks[a].text; len(w) > 1 && w[0] == '-' && w[1] != '-' && strings.ContainsRune(w, 'c') { + return true + } + } + } + return false +} + +// runner marks the simple command c, the tokens s to e whose program prog is +// at i, as a run of a test or build runner when it is one, in the group of +// the runs with the same words; any other command is returned as it is. +func (p *lineParser) runner(c *simple, s, i, e int, prog string) (command, bool) { + toks := p.toks + ri, name, ok := runnerPrefix(toks[:e], i, prog) + if !ok { + return c, true + } + var kind string + switch { + case runnerOn(toks, ri, name, e, testCommands): + kind = MaskedTest + case runnerOn(toks, ri, name, e, buildCommands): + kind = MaskedBuild + default: + return c, true + } + key := p.runnerWords(ri, e) + g, seen := p.groups[key] + if !seen { + g = len(p.runners) + p.groups[key] = g + p.runners = append(p.runners, runnerGroup{key: key, kind: kind}) + } + p.runners[g].starts = append(p.runners[g].starts, s) + p.runners[g].last = s + c.op, c.runner = opRunner, g + return c, true +} + +// assignments reads a command of assignments only, `rc=$?` and the like. +// False when a word is not an assignment, or is one whose value holds an +// expansion this tokenizer does not parse and is not one value reads. +func (p *lineParser) assignments(ws []int) ([]assignment, bool) { + var out []assignment + for _, j := range ws { + t := p.toks[j] + if t.meta || !isShellAssignment(t) { + return nil, false + } + name, v := splitAssignment(t.text) + val := p.valueOf(token{text: v, quotedAt: -1}) + if t.opaque && val.kind == valUnknown { + return nil, false + } + out = append(out, assignment{name, val}) + } + for _, a := range out { + p.assigned[a.name] = true + } + return out, true +} + +// splitAssignment splits NAME=value; a subscript or += leaves a name no +// reader looks up. +func splitAssignment(w string) (name, val string) { + eq := strings.IndexByte(w, '=') + return w[:eq], w[eq+1:] +} + +// isName reports a shell variable name. +func isName(s string) bool { + if s == "" { + return false + } + for j := 0; j < len(s); j++ { + c := s[j] + if !(c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c == '_' || c >= '0' && c <= '9' && j > 0) { + return false + } + } + return true +} + +// valueOf reads a word as a value: digits (or a sign and digits), $?, or a +// variable, $NAME or ${NAME}. PIPESTATUS is not read: zsh has none, so its +// value is unknown. +func (p *lineParser) valueOf(t token) value { + w := t.text + if t.meta { + return value{} + } + switch { + case w == "$?": + return value{kind: valStatus} + case strings.HasPrefix(w, "${") && strings.HasSuffix(w, "}") && isName(w[2:len(w)-1]): + return value{kind: valVar, name: w[2 : len(w)-1]} + case strings.HasPrefix(w, "$") && isName(w[1:]): + return value{kind: valVar, name: w[1:]} + } + digits := strings.TrimPrefix(w, "-") + if digits == "" || strings.Trim(digits, "0123456789") != "" || len(digits) > 18 { + return value{} + } + n, err := strconv.ParseInt(w, 10, 64) + if err != nil { + return value{} + } + return value{kind: valLiteral, n: int(((n % 256) + 256) % 256)} +} + +// readsStatus reports a word of [s, e) that reads a status: $?, PIPESTATUS, +// or a variable an assignment on the line set. +func (p *lineParser) readsStatus(s, e int) bool { + for j := s; j < e; j++ { + w := p.toks[j].text + if strings.Contains(w, "$?") || strings.Contains(w, "PIPESTATUS") { + return true + } + for name := range p.assigned { + if strings.Contains(w, "$"+name) || strings.Contains(w, "${"+name) { + return true + } + } + } + return false +} + +// setOptions reads a `set` command's arguments for the two options that +// change whether a status reaches the line's: errexit (`-e`, in any cluster +// of short options such as -euo, or `-o errexit`) and pipefail (`-o +// pipefail`), and their `+` forms, which turn them off. A cluster holding o +// takes the next word as the option's name. False on a word this tokenizer +// cannot vouch for, and on a cluster where o is not last, which bash and zsh +// read differently. +func setOptions(toks []token, args []int) ([]setOpt, bool) { + var out []setOpt + for n := 0; n < len(args); n++ { + t := toks[args[n]] + if t.meta || t.opaque { + return nil, false + } + w := t.text + if w == "--" || w == "-" { + break + } + if len(w) < 2 || w[0] != '-' && w[0] != '+' { + continue + } + on := w[0] == '-' + for k, c := range w[1:] { + switch c { + case 'e': + out = append(out, setOpt{errexit: true, on: on}) + case 'o': + if k < len(w)-2 { + // o before the cluster's end, `-opipefail`: bash rejects + // the word and zsh reads the rest as the name. + return nil, false + } + if n+1 >= len(args) { + continue + } + n++ + switch toks[args[n]].text { + case "pipefail": + out = append(out, setOpt{errexit: false, on: on}) + case "errexit": + out = append(out, setOpt{errexit: true, on: on}) + } + } + } + } + return out, true +} diff --git a/test/mutation/mutants/MS_masked.py b/test/mutation/mutants/MS_masked.py index 969fd290..7018ef16 100644 --- a/test/mutation/mutants/MS_masked.py +++ b/test/mutation/mutants/MS_masked.py @@ -33,69 +33,69 @@ SH = "TestStatusMasked|TestRunnerDigest|TestMasked_" # The parser runs the line twice in the abstract, the runner failing and # passing (statusMasked); each mutant breaks one rule of that run. -m("MS a pipe after the runner no longer masks it", "internal/shape/masked.go", +m("MS a pipe after the runner no longer masks it", "internal/shape/masked_eval.go", "\t\ts := statuses[len(statuses)-1]\n", "\t\ts := statuses[0]\n", SH) -m("MS set -o pipefail is not read", "internal/shape/masked.go", +m("MS set -o pipefail is not read", "internal/shape/masked_eval.go", "\t\t\t\tst.pipefail = o.on\n", "\t\t\t\tst.pipefail = false\n", SH) -m("MS set -e is not read", "internal/shape/masked.go", +m("MS set -e is not read", "internal/shape/masked_eval.go", "\t\t\t\tst.errexit = o.on\n", "\t\t\t\tst.errexit = false\n", SH) -m("MS errexit is not ignored before && or ||", "internal/shape/masked.go", +m("MS errexit is not ignored before && or ||", "internal/shape/masked_eval.go", "\t\tignored := noErr || k < len(ao.pipes)-1 || pl.neg\n", "\t\tignored := noErr || pl.neg\n", SH) -m("MS && masks the runner before it", "internal/shape/masked.go", +m("MS && masks the runner before it", "internal/shape/masked_eval.go", "if ao.ops[k-1] == \"&&\" && st.status != 0 ||", "if ao.ops[k-1] == \"&&\" && false ||", SH) -m("MS ; and a newline never mask", "internal/shape/masked.go", +m("MS ; and a newline never mask", "internal/shape/masked_eval.go", "\t\tev.andOr(st, it.ao, noErr)\n", "\t\tev.andOr(st, it.ao, noErr)\n\t\tif st.status > 0 {\n\t\t\treturn\n\t\t}\n", SH) -m("MS a trailing ; masks", "internal/shape/masked.go", +m("MS a trailing ; masks", "internal/shape/masked_parse.go", "\t\tcase p.op(p.pos, \";\"):\n\t\t\tp.pos++\n", "\t\tcase p.op(p.pos, \";\"):\n\t\t\tp.pos++\n\t\t\tit.bg = p.atTerminator()\n", SH) -m("MS || never masks", "internal/shape/masked.go", +m("MS || never masks", "internal/shape/masked_eval.go", "|| ao.ops[k-1] == \"||\" && st.status == 0 {", "|| ao.ops[k-1] == \"||\" {", SH) -m("MS an exit after || is not read", "internal/shape/masked.go", +m("MS an exit after || is not read", "internal/shape/masked_eval.go", "\tcase opExit:\n\t\tswitch len(c.args) {", "\tcase -1:\n\t\tswitch len(c.args) {", SH) -m("MS an exit does not end the line", "internal/shape/masked.go", +m("MS an exit does not end the line", "internal/shape/masked_eval.go", "\t\tst.exited = true\n\tcase opRunner:", "\tcase opRunner:", SH) -m("MS false after || is not read", "internal/shape/masked.go", +m("MS false after || is not read", "internal/shape/masked_eval.go", "\tcase opFalse:\n\t\tst.status = 1\n", "\tcase opFalse:\n\t\tst.status = 0\n", SH) -m("MS a group is run apart from the line", "internal/shape/masked.go", +m("MS a group is run apart from the line", "internal/shape/masked_eval.go", "\tcase *group:\n\t\tev.list(st, c.body, noErr)\n", "\tcase *group:\n\t\tev.list(st.copy(), c.body, noErr)\n", SH) -m("MS a subshell's exit ends the line", "internal/shape/masked.go", +m("MS a subshell's exit ends the line", "internal/shape/masked_eval.go", "\t\tst.status = sub.status\n\tcase *group:", "\t\tst.status, st.exited = sub.status, sub.exited\n\tcase *group:", SH) -m("MS a failed condition with no else keeps its status", "internal/shape/masked.go", +m("MS a failed condition with no else keeps its status", "internal/shape/masked_eval.go", "\t\t\tev.list(st, *c.els, noErr)\n\t\t\treturn\n\t\t}\n\t\tst.status = 0\n", "\t\t\tev.list(st, *c.els, noErr)\n\t\t\treturn\n\t\t}\n", SH) -m("MS ! is not read", "internal/shape/masked.go", +m("MS ! is not read", "internal/shape/masked_eval.go", "\tif pl.neg && st.status >= 0 {", "\tif false {", SH) -m("MS a runner sent to the background with & is not masked", "internal/shape/masked.go", +m("MS a runner sent to the background with & is not masked", "internal/shape/masked_eval.go", "\t\t\tev.andOr(st.copy(), it.ao, true)\n\t\t\tst.status = 0\n", "\t\t\tev.andOr(st, it.ao, noErr)\n", SH) -m("MS the same runner twice is two runners", "internal/shape/masked.go", +m("MS the same runner twice is two runners", "internal/shape/masked_simple.go", "\tg, seen := p.groups[key]\n", "\tg, seen := p.groups[key]\n\tseen = false\n", SH) -m("MS a status kept in a variable is not read", "internal/shape/masked.go", +m("MS a status kept in a variable is not read", "internal/shape/masked_eval.go", "\t\t\tst.vars[a.name] = ev.value(st, a.val)\n", "\t\t\tst.vars[a.name] = -1\n", SH) -m("MS a loop that holds a runner is read as succeeding", "internal/shape/masked.go", +m("MS a loop that holds a runner is read as succeeding", "internal/shape/masked_parse.go", "\t\tcase opRunner, opExit, opStatusRead, opSet, opAssign, opCd:\n\t\t\tl.unsure = true\n", "", SH) -m("MS a shell's -c line is read as a word", "internal/shape/masked.go", +m("MS a shell's -c line is read as a word", "internal/shape/masked_simple.go", "w[1] != '-' && strings.ContainsRune(w, 'c') {", "w[1] != '-' && strings.ContainsRune(w, 'c') && false {", SH) m("MS a line the run is not sure of is none", "internal/shape/masked.go", "\t\t\t// Not sure: null.\n\t\t\treturn nil, nil\n", "\t\t\tcontinue\n", SH) m("MS a runner the run never reaches is judged", "internal/shape/masked.go", "\t\tif f < 0 || pass < 0 || !ran {", "\t\tif f < 0 || pass < 0 || !ran && false {", SH) -m("MS an exit 0 after || keeps the failure", "internal/shape/masked.go", +m("MS an exit 0 after || keeps the failure", "internal/shape/masked_eval.go", "\t\t\tst.status = ev.value(st, c.args[0])\n", "\t\t\tif v := ev.value(st, c.args[0]); v != 0 {\n\t\t\t\tst.status = v\n\t\t\t}\n", SH) -m("MS an exit 256 after || keeps the failure", "internal/shape/masked.go", +m("MS an exit 256 after || keeps the failure", "internal/shape/masked_simple.go", "\treturn value{kind: valLiteral, n: int(((n % 256) + 256) % 256)}", "\treturn value{kind: valLiteral, n: int(n)}", SH) -m("MS an exit in a pipe after || is the line's exit", "internal/shape/masked.go", +m("MS an exit in a pipe after || is the line's exit", "internal/shape/masked_eval.go", "\t\t\tstatuses[n] = sub.status\n", "\t\t\tif sub.exited {\n\t\t\t\tst.status, st.exited = sub.status, true\n\t\t\t\treturn\n\t\t\t}\n\t\t\tstatuses[n] = sub.status\n", SH) -m("MS false | x after || keeps the failure", "internal/shape/masked.go", +m("MS false | x after || keeps the failure", "internal/shape/masked_eval.go", "\t\tif st.pipefail {\n\t\t\ts = 0\n", "\t\tif true {\n\t\t\ts = 0\n", SH) -m("MS errexit fires in the zsh run", "internal/shape/masked.go", +m("MS errexit fires in the zsh run", "internal/shape/masked_eval.go", "if st.errexit && !ignored && !ev.zsh && st.status != 0 {", "if st.errexit && !ignored && st.status != 0 {", SH) -m("MS bash and zsh disagreeing is not null", "internal/shape/masked.go", +m("MS bash and zsh disagreeing is not null", "internal/shape/masked_eval.go", "\tif zf < 0 || zpass < 0 || (f == 0 || f == pass) != (zf == 0 || zf == zpass) {", "\tif zf < 0 || zpass < 0 {", SH) -m("MS a line that sets errexit skips the zsh run", "internal/shape/masked.go", +m("MS a line that sets errexit skips the zsh run", "internal/shape/masked_eval.go", "\tif !p.errexit {\n\t\t// Without errexit the two shells run it alike.\n", "\tif true {\n\t\t// Without errexit the two shells run it alike.\n", SH) -m("MS the line runs only as bash runs it", "internal/shape/masked.go", +m("MS the line runs only as bash runs it", "internal/shape/masked_eval.go", "\tev := &evaluator{p: p, target: g, fail: fail, zsh: zsh}\n", "\tev := &evaluator{p: p, target: g, fail: fail}\n", SH) -m("MS set -opipefail is read as short options", "internal/shape/masked.go", +m("MS set -opipefail is read as short options", "internal/shape/masked_simple.go", "\t\t\t\tif k < len(w)-2 {\n", "\t\t\t\tif k < 0 {\n", SH) -m("MS the pipeline after && ends at its first stage", "internal/shape/masked.go", +m("MS the pipeline after && ends at its first stage", "internal/shape/masked_parse.go", "\t\tao.pipes = append(ao.pipes, pl)\n", "\t\tif len(ao.pipes) > 0 {\n\t\t\tpl.stages = pl.stages[:1]\n\t\t}\n\t\tao.pipes = append(ao.pipes, pl)\n", SH) m("MS a here-string is read as a here-document", "internal/shape/tokenize.go", "\tif l.docs && c == '<' && j-i == 2 && (j >= len(s) || s[j] != '<') {", "\tif l.docs && c == '<' && j-i == 2 {", SH) @@ -103,7 +103,7 @@ "\t\tif l.grouped || l.expanded {", "\t\tif l.grouped {", SH) m("MS only the first here-document on a line is skipped", "internal/shape/tokenize.go", "\t\tl.pending = append(l.pending, j)\n", "\t\tif len(l.pending) == 0 {\n\t\t\tl.pending = append(l.pending, j)\n\t\t}\n", SH) -m("MS a group's close is a word of the runner digest", "internal/shape/masked.go", +m("MS a group's close is a word of the runner digest", "internal/shape/masked_parse.go", "\t\tcase t.text == \")\":\n\t\t\t// A subshell's close: the command ends before it.\n\t\t\treturn j, true\n", "", SH) m("MS make is no build runner", "internal/shape/masked.go", "\t{\"make\"}, {\"gmake\"},\n", "\t{\"gmake\"},\n", SH) @@ -115,21 +115,21 @@ "\t\tl.pending = append(l.pending, j)\n", "\t\t_ = j\n", SH) m("MS a comment is read as words in the list walk", "internal/shape/tokenize.go", "\t\tcase docs && c == '#' && !l.w.started:", "\t\tcase false && docs && c == '#' && !l.w.started:", SH) -m("MS the runner digest keeps a redirection's target", "internal/shape/masked.go", +m("MS the runner digest keeps a redirection's target", "internal/shape/masked_parse.go", "\t\t\tj = operatorEnd(toks, j) + 1\n", "\t\t\tj = operatorEnd(toks, j)\n", SH) -m("MS the runner digest runs to the end of the line", "internal/shape/masked.go", +m("MS the runner digest runs to the end of the line", "internal/shape/masked_simple.go", "\tkey := p.runnerWords(ri, e)\n", "\tkey := p.runnerWords(ri, len(toks))\n", SH) -m("MS the runner digest is not keyed", "internal/shape/masked.go", +m("MS the runner digest is not keyed", "internal/shape/masked_eval.go", "\td := digest(key, \"\\x00runner\",", "\td := digest(nil, \"\\x00runner\",", SH) m("MS a runner whose failure reaches the line carries no runner digest", "internal/shape/masked.go", "\t\treturn &none, p.runnerDigest(reach, key)", "\t\treturn &none, nil", SH) m("MS two hidden runners carry one's digest", "internal/shape/masked.go", "\t\tif len(hidden) > 1 {\n\t\t\t// No one later run follows up two runners.\n\t\t\treturn &k, nil\n\t\t}\n", "", SH) -m("MS a runner after a cd keeps its digest", "internal/shape/masked.go", +m("MS a runner after a cd keeps its digest", "internal/shape/masked_eval.go", "\tif p.runners[g].displaced {\n\t\treturn nil\n\t}\n", "", SH) -m("MS a folded leading cd displaces the runner", "internal/shape/masked.go", +m("MS a folded leading cd displaces the runner", "internal/shape/masked_eval.go", "\t\tif s.op != opCd || s.start < 3*folded && s.start%3 == 0 {", "\t\tif s.op != opCd {", SH) -m("MS an expansion in the runner's words loses its digest", "internal/shape/masked.go", +m("MS an expansion in the runner's words loses its digest", "internal/shape/masked_eval.go", "\t\tif t.opaque {\n\t\t\tw = \"\\x02\" + w\n\t\t}\n", "\t\tif t.opaque {\n\t\t\treturn \"\"\n\t\t}\n", SH) m("MS a backslash at a comment's end joins the next line", "internal/shape/join.go", "\t\tcase comments && c == '#' && !inSingle && !inDouble && wordStart(b.String()):", "\t\tcase false:", SH) From 5a77a0a9e908bddf31cf50965f95ab1b5482b748 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 13:44:59 -0700 Subject: [PATCH 16/50] shape: pin that Derive reads a line three times The mutant that puts the leading-cd count back on a fourth reading of the line was red only while the escaped-blanks line sat near the RES-29 test's 40 MiB; once tokenEstimate stopped over-sizing that line's token slice it passed. The fold is a cost, not a behaviour, so it is pinned as one: TestRES29_DeriveReadsTheLineThreeTimes measures Derive's allocation past the command's decoding against one tokenizeProgram reading, on the two lines where a reading costs megabytes, and holds it to three and a half readings. Today it is 3.00 for 2.8 million escaped blanks and 2.75 for 2.8 million backslash-newlines; a fourth reading puts it at 4.00 and 3.75. --- internal/shape/res29_alloc_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/shape/res29_alloc_test.go b/internal/shape/res29_alloc_test.go index 6dcae035..1003a748 100644 --- a/internal/shape/res29_alloc_test.go +++ b/internal/shape/res29_alloc_test.go @@ -127,3 +127,29 @@ func TestRES29_ContinuationsPastTheKeptOffsets(t *testing.T) { t.Errorf("one past maxJoins: program %v, want echo", show(s.Program)) } } + +// TestRES29_DeriveReadsTheLineThreeTimes: Derive lexes a shell line three +// times -- for argc, the program and the masked status -- and counts the +// leading cds from the program search's tokens rather than lexing a fourth +// time (P-12's fold). Past the decoding of the command, it allocates at most +// three and a half times one reading of the line, on two lines where each +// reading costs megabytes: one word of 2.8 million escaped blanks, copied by +// each reading, and 2.8 million backslash-newlines, joined by two of them. +// A fourth reading puts it at four. +func TestRES29_DeriveReadsTheLineThreeTimes(t *testing.T) { + for _, tc := range pathologicalLines(t) { + if tc.name != "escaped blanks" && tc.name != "backslash-newlines" { + continue + } + cmd, ok := commandField(tc.in) + if !ok { + t.Fatalf("%s: no command", tc.name) + } + decode := allocated(func() { commandField(tc.in) }) + reading := allocated(func() { tokenizeProgram(cmd) }) + derive := allocated(func() { Derive("Bash", tc.in, benchKey) }) + if ratio := float64(derive-decode) / float64(reading); ratio > 3.5 { + t.Errorf("%s: Derive allocated %.2f readings' worth past the decoding, want at most 3.5", tc.name, ratio) + } + } +} From 175a281fcad28b97e6b953bbcb979b1cbf9bb649 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 13:45:31 -0700 Subject: [PATCH 17/50] hook: state the measured rss peak as measured The final build's here-document case peaked at 58.0 MiB, so "under 58" in MaxPayloadBytes' comment and the RES-29 test's was not true of every run. Both now say 58 MiB or less, which is what twelve 8 MiB payload shapes measured on two builds of this tree. --- internal/hook/handle.go | 2 +- internal/shape/res29_alloc_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/hook/handle.go b/internal/hook/handle.go index 1243f073..2d2c2c52 100644 --- a/internal/hook/handle.go +++ b/internal/hook/handle.go @@ -32,7 +32,7 @@ import ( // below stops at maxLeadingCd (RES-10). That is measured, not proved: on the // sixteen 8 MiB line shapes TestRES29_ShapeOfEveryPathologicalLineIsBounded // reads, no reader allocates more than five times the payload, and the hook -// peaked under 58 MiB of RSS on each shape tried. +// peaked at 58 MiB of RSS or less on each shape tried. const MaxPayloadBytes = hookio.MaxPayloadBytes // maxLeadingCd bounds the leading-cd fold in startDirectory: see diff --git a/internal/shape/res29_alloc_test.go b/internal/shape/res29_alloc_test.go index 1003a748..d6bbce3c 100644 --- a/internal/shape/res29_alloc_test.go +++ b/internal/shape/res29_alloc_test.go @@ -77,7 +77,7 @@ func allocated(f func()) uint64 { // after https:// cost a parse error. And each reader decoded the command // into a buffer grown from nothing and copied it again. With the hook's own // intake those drove a hook to 87, 84 and 178 MiB of RSS against RES-29's -// 64; every shape here now peaks under 58. +// 64; every shape here now peaks at 58 or less. func TestRES29_ShapeOfEveryPathologicalLineIsBounded(t *testing.T) { for _, tc := range pathologicalLines(t) { if len(tc.in) > 8<<20 { From d4c68771e69623401df83b229fa53b46c353b0d2 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 14:32:17 -0700 Subject: [PATCH 18/50] mutation: name only lld-9's own tests in its mutant file RUBRIC_lld-9_word-reset.py judged its second mutant by "TestRubricLLD9_|TestTokenize", and a RUBRIC_ file names only the TestRubric_ tests of its own id (DEL-23). TestRubricLLD9_ alone turns it red: the reset test checks that a fresh word is quoted at -1, which a newWord that returns the zero word is not. Both mutants of the file are red under TestRubricLLD9_. --- test/mutation/mutants/RUBRIC_lld-9_word-reset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mutation/mutants/RUBRIC_lld-9_word-reset.py b/test/mutation/mutants/RUBRIC_lld-9_word-reset.py index 1b7c7d14..a30433fd 100644 --- a/test/mutation/mutants/RUBRIC_lld-9_word-reset.py +++ b/test/mutation/mutants/RUBRIC_lld-9_word-reset.py @@ -8,4 +8,4 @@ m("RUBRIC lld-9 a fresh word is not quoted at -1", "internal/shape/tokenize.go", "func newWord() word { return word{quotedAt: -1} }", "func newWord() word { return word{} }", - "TestRubricLLD9_|TestTokenize") + "TestRubricLLD9_") From a1fde89cf3614e64c6ef02a191adbce6e4c0f1cc Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 14:32:45 -0700 Subject: [PATCH 19/50] forget: ask about a host in the spelling it is recorded in Recording `curl https://167838211/x` stores the host 10.1.2.3, since IP literals were given one spelling, but `forget --host` still compared the value as typed. `forget --host 167838211` matched no record and no baseline key, printed "forgot 0 records naming 167838211" and exited 0, and the host the user asked to forget stayed on disk. Before the change the record held 167838211 and the same forget matched, so for these spellings the change had made forget worse. The same split held for every inet_aton form, expanded or upper-case IPv6, IPv4-mapped addresses, and names typed with a port, a root dot or another case. store.ForgetHost and baseline.ForgetFile now canonicalise the value with host.Canonical and refuse one it rejects with store.ErrNotAHost, rather than removing nothing and reading as done. Each stored host is compared through host.Same: equal as stored, or equal once canonicalised. Records and baselines written before IP literals had one spelling hold them as the call wrote them, and comparing only the stored text would miss those. A baseline file that does not parse is searched as bytes for both the canonical and the typed spelling. The gap record's digest is the canonical host's, which is the spelling the sandbox trail's events carry, so the report's suppression of a forgotten host now holds for any spelling. cmdForget refuses a value that names no host before opening anything, and the summary names the canonical host ("forgot 1 record naming 10.1.2.3"). The refusal does not echo the value. The old summary repeated a value carrying userinfo back to the terminal. Canonicalising only the command-line value was considered and rejected. Records from before one spelling would stop matching their own typed form, and library callers would still digest the raw value. Storing both spellings was also rejected, because it stores more of the most sensitive field and changes the schema. Tests: TestRubricLLD4_ForgetHostMatchesEverySpelling, TestRubricLLD4_ForgetHostRefusesWhatIsNotAHost (store), TestRubricLLD4_ForgetFileMatchesEverySpelling, TestRubricLLD4_ForgetFileRemovesAnUnreadableFileNamingAnySpelling, TestRubricLLD4_ForgetFileRefusesWhatIsNotAHost (baseline) and TestRubricLLD4_ForgetCommandAsksInTheRecordedSpelling (records 10.1.2.3 through shape.Hosts, forgets 167838211). Eight mutants are added to RUBRIC_lld-4_spellings.py. Three TRU-58 mutants and one hld-2 mutant anchored on the old forgetLocked are re-anchored and still red. Rubric fix list entry 30 (lld-4). CWE-178, CWE-212, CWE-532. --- cmd/rashomon/forget_test.go | 63 +++++++++++++ cmd/rashomon/main.go | 54 ++++++----- internal/baseline/baseline.go | 36 ++++++-- internal/baseline/rubric_lld4_forget_test.go | 92 +++++++++++++++++++ internal/host/host.go | 14 +++ internal/store/gaps.go | 47 +++++----- internal/store/rubric_lld4_forget_test.go | 91 ++++++++++++++++++ .../H-25_forget-host-summary-before-error.py | 8 +- test/mutation/mutants/NONO_nono.py | 28 ++++-- test/mutation/mutants/REC-32_reader-limits.py | 18 ++-- .../mutants/RUBRIC_hld-2_baseline-lock.py | 2 +- .../mutants/RUBRIC_lld-4_spellings.py | 25 +++++ .../mutation/mutants/TRU-58_baseline-basis.py | 6 +- 13 files changed, 399 insertions(+), 85 deletions(-) create mode 100644 internal/baseline/rubric_lld4_forget_test.go create mode 100644 internal/store/rubric_lld4_forget_test.go diff --git a/cmd/rashomon/forget_test.go b/cmd/rashomon/forget_test.go index 7515990d..7f13a281 100644 --- a/cmd/rashomon/forget_test.go +++ b/cmd/rashomon/forget_test.go @@ -2,15 +2,18 @@ package main import ( "bytes" + "encoding/json" "errors" "os" "path/filepath" + "reflect" "runtime" "strings" "testing" "time" "github.com/altrace-dev-role/rashomon/internal/baseline" + "github.com/altrace-dev-role/rashomon/internal/shape" "github.com/altrace-dev-role/rashomon/internal/store" ) @@ -344,3 +347,63 @@ func TestCmdForget_SaysNothingWasForgottenWhenTheListingFailed(t *testing.T) { t.Errorf("cmdForget with the runs directory unlistable printed %q, want nothing claiming records were forgotten", got) } } + +// TestRubricLLD4_ForgetCommandAsksInTheRecordedSpelling: `curl +// https://167838211/x` records the host 10.1.2.3, and `forget --host +// 167838211` must forget it, from the records and from every baseline, and +// say which host it forgot. It compared the raw argument, matched nothing and +// printed "forgot 0 records", a forget that read as done while the host +// stayed on disk. An argument that names no host is refused before anything +// is opened, and the refusal does not echo it: it may carry a credential. +func TestRubricLLD4_ForgetCommandAsksInTheRecordedSpelling(t *testing.T) { + root := t.TempDir() + t.Setenv("RASHOMON_HOME", root) + // Refused before a store exists, where an accepted value would print + // "nothing has been recorded here" and exit 0. + for _, bad := range []string{"deploy:hunter2@10.1.2.3", "[10.1.2.3]"} { + var out, errOut bytes.Buffer + if code := run([]string{"forget", "--host", bad}, nil, &out, &errOut); code == exitOK { + t.Errorf("forget --host %q exited 0: %s", bad, out.String()) + } + if strings.Contains(errOut.String()+out.String(), "hunter2") { + t.Errorf("forget --host echoed the credential: %s%s", out.String(), errOut.String()) + } + } + + input, err := json.Marshal(map[string]string{"command": "curl https://167838211/x"}) + if err != nil { + t.Fatal(err) + } + hosts, _ := shape.Hosts("Bash", input) + if !reflect.DeepEqual(hosts, []string{"10.1.2.3"}) { + t.Fatalf("premise: the call records %q, want [10.1.2.3]", hosts) + } + st, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + if err := st.AppendDeclaration(store.Declaration{ + Type: store.TypeDeclaration, SchemaVersion: store.SchemaVersion, SessionID: "s-lld4", + ToolUseID: "toolu_lld4", ToolName: "Bash", RecordedAtMS: 1_000, Hosts: hosts, + }); err != nil { + t.Fatal(err) + } + if res := baseline.Update(root, "/a-project", "s-lld4", time.UnixMilli(1_000), hosts); res.Err != nil { + t.Fatal(res.Err) + } + + var out, errOut bytes.Buffer + if code := run([]string{"forget", "--host", "167838211"}, nil, &out, &errOut); code != exitOK { + t.Fatalf("forget --host 167838211 exited %d: %s", code, errOut.String()) + } + if want := "rashomon: forgot 1 record naming 10.1.2.3 across 1 run; 1 gap record written; 1 project baseline(s) cleared\n"; out.String() != want { + t.Errorf("forget --host 167838211 printed %q, want %q", out.String(), want) + } + left, err := st.ReadRun("s-lld4") + if err != nil { + t.Fatal(err) + } + if len(left.Declarations) != 0 { + t.Errorf("the declaration naming 10.1.2.3 survived the forget: %+v", left.Declarations) + } +} diff --git a/cmd/rashomon/main.go b/cmd/rashomon/main.go index 2e0f95ff..14dfa519 100644 --- a/cmd/rashomon/main.go +++ b/cmd/rashomon/main.go @@ -1711,28 +1711,18 @@ func cmdSpend(args []string, stdout io.Writer) error { // naming both is refused rather than resolved. func cmdForget(args []string, stdout io.Writer) error { var from, to *time.Time - target := "" + hostArg := "" for i := 0; i < len(args); i++ { switch flag := args[i]; flag { case "--host": if i+1 >= len(args) { return errors.New("--host needs a value") } - // Canonicalised HERE, before anything is opened, and the canonical - // name is what every step after it sees: the record match, the - // digest, the baselines and the line that says what was forgotten. - // Every host rashomon holds went through host.Canonical, so taken - // as typed, "GitHub.com" or "github.com:443" forgot nothing while - // the line said later reports would leave it out. The value is not - // echoed in the refusal: it may carry a credential, and stderr - // lands wherever the caller's output does, a transcript included. - h, ok := host.Canonical(args[i+1]) - if !ok { - return errors.New("--host is not a hostname: give the destination's name, " + - "such as github.com (a scheme and a port are dropped; a path, " + - "credentials or spaces are refused)") - } - target = h + // Taken as typed here and canonicalised by forgetHost, before + // anything is opened: the store and the baselines are asked about + // the canonical name, and an unparsed baseline is searched for the + // typed one as well. + hostArg = args[i+1] i++ case "--since", "--before": if i+1 >= len(args) { @@ -1753,10 +1743,10 @@ func cmdForget(args []string, stdout io.Writer) error { } } switch { - case target != "" && (from != nil || to != nil): + case hostArg != "" && (from != nil || to != nil): return errors.New("--host removes by destination and --since/--before remove by time; pass one kind or the other") - case target != "": - return forgetHost(target, stdout) + case hostArg != "": + return forgetHost(hostArg, stdout) case from != nil && to != nil: return errors.New("--since and --before name opposite ends of the store's timeline; pass one or the other") case from == nil && to == nil: @@ -1918,7 +1908,21 @@ another install stands down: it records nothing in this environment. // A baseline/ directory exists only where a report has run on this machine, // because a report writes one only then. Without it the output is one line // saying what was removed; with it, the baseline count follows. -func forgetHost(host string, stdout io.Writer) error { +// +// The value is asked about in the one spelling hosts are recorded in +// (host.Canonical), which is also the spelling the summary names: `--host +// 167838211` forgets the records of 10.1.2.3 and says so. A value that names +// no host is refused before anything is opened, and the refusal does not echo +// it: a value carrying user@ is refused because it may carry a credential, +// and stderr lands wherever the caller's output does, a transcript included. +func forgetHost(raw string, stdout io.Writer) error { + name, ok := host.Canonical(raw) + if !ok { + return errors.New("--host is not a hostname: give the destination's name or address, " + + "such as pypi.org or 10.1.2.3, or a URL such as https://pypi.org/simple " + + "(a scheme, a port and a URL's path are dropped; a value with user@ in it, " + + "spaces inside or a path with no scheme is refused, not stripped)") + } st, err := openStoreForRead() if errors.Is(err, store.ErrNoStore) { fmt.Fprintln(stdout, nothingRecordedHere) @@ -1929,7 +1933,7 @@ func forgetHost(host string, stdout io.Writer) error { } // As in cmdForget: what was forgotten is printed, and baselines cleared, // before a run that could not be rewritten fails the command. - gaps, forgetErr := st.ForgetHost(host, time.Now()) + gaps, forgetErr := st.ForgetHost(raw, time.Now()) if forgetErr != nil && !errors.Is(forgetErr, store.ErrForgetIncomplete) { // Failed before any run was forgotten, and perhaps before the // store-level record landed: the line below would claim later @@ -1952,10 +1956,10 @@ func forgetHost(host string, stdout io.Writer) error { // either error is returned. A forget that clears some runs or some // baselines and then fails must still say so: a caller who sees only the // error cannot tell that part of it already happened. - cleared, hadBaselines, clearErr := clearBaselines(st.Root(), host) + cleared, hadBaselines, clearErr := clearBaselines(st.Root(), raw) fmt.Fprintf(stdout, "rashomon: forgot %s naming %s across %s; %s written", - countOf(total, "record"), host, countOf(len(gaps), "run"), countOf(len(gaps), "gap record")) + countOf(total, "record"), name, countOf(len(gaps), "run"), countOf(len(gaps), "gap record")) // Said when nothing was removed, because that is when "forgot 0 records" // reads as "nothing happened" -- and before #45 it was true: the host came // back under the sandbox heading of the next report. The store-level @@ -1987,7 +1991,7 @@ func countOf(n int, noun string) string { // // A file it cannot clear does not stop it: the host still leaves every other // project's baseline, and the failures are returned together. -func clearBaselines(root, host string) (int, bool, error) { +func clearBaselines(root, raw string) (int, bool, error) { dir := filepath.Join(root, "baseline") entries, err := os.ReadDir(dir) if errors.Is(err, fs.ErrNotExist) { @@ -2002,7 +2006,7 @@ func clearBaselines(root, host string) (int, bool, error) { if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { continue } - n, err := baseline.ForgetFile(filepath.Join(dir, e.Name()), host) + n, err := baseline.ForgetFile(filepath.Join(dir, e.Name()), raw) if err != nil { failed = append(failed, err) continue diff --git a/internal/baseline/baseline.go b/internal/baseline/baseline.go index cee1bff2..dce756cc 100644 --- a/internal/baseline/baseline.go +++ b/internal/baseline/baseline.go @@ -29,6 +29,7 @@ import ( "time" "github.com/altrace-dev-role/rashomon/internal/fault" + "github.com/altrace-dev-role/rashomon/internal/host" "github.com/altrace-dev-role/rashomon/internal/store" ) @@ -385,10 +386,20 @@ func digest(key string) string { // remembers it. That is the conservative direction: a host the user asked to // forget must not survive in a project they did not think to name. // +// The host is matched in any spelling: raw is canonicalised (host.Canonical) +// and compared with each held host as host.Same compares them, because a +// baseline holds hosts as its sessions' records named them, and a file +// written before IP literals had one spelling holds 167838211 where today's +// holds 10.1.2.3. A raw value that names no host is store.ErrNotAHostname. +// // It takes the lock Update takes, so a forget and a render cannot each save a // copy loaded before the other's change. -func ForgetFile(path, host string) (int, error) { - if path == "" || host == "" { +func ForgetFile(path, raw string) (int, error) { + name, ok := host.Canonical(raw) + if !ok { + return 0, store.ErrNotAHostname + } + if path == "" { return 0, nil } release, err := store.LockPath(filepath.Join(filepath.Dir(path), lockName)) @@ -400,11 +411,12 @@ func ForgetFile(path, host string) (int, error) { return 0, err } defer release() - return forgetLocked(path, host) + return forgetLocked(path, raw, name) } -// forgetLocked is ForgetFile under the lock. -func forgetLocked(path, host string) (int, error) { +// forgetLocked is ForgetFile under the lock, for the host raw whose canonical +// spelling is name. +func forgetLocked(path, raw, name string) (int, error) { body, err := readFile(path) if errors.Is(err, fs.ErrNotExist) { // Listed, then gone before it was read: nothing left to clear. @@ -423,18 +435,24 @@ func forgetLocked(path, host string) (int, error) { // whole and the project establishes its baseline again, which is the // conservative direction: refusing would leave the forgotten host // sitting in a file this forget cannot touch any other way. Not - // holding it, the file is no business of this forget. - if !namesHost(body, host) { + // holding it, the file is no business of this forget. Unparsed, its + // hosts cannot be canonicalised, so both spellings are looked for. + if !namesHost(body, name) && !namesHost(body, raw) { return 0, nil } return removeWhole(path) } - _, held := f.Hosts[host] + held := false + for h := range f.Hosts { + if host.Same(h, name) { + delete(f.Hosts, h) + held = true + } + } if f.Basis == Basis { if !held { return 0, nil } - delete(f.Hosts, host) if err := save(filepath.Dir(path), path, f); err != nil { return 0, err } diff --git a/internal/baseline/rubric_lld4_forget_test.go b/internal/baseline/rubric_lld4_forget_test.go new file mode 100644 index 00000000..00a08f97 --- /dev/null +++ b/internal/baseline/rubric_lld4_forget_test.go @@ -0,0 +1,92 @@ +package baseline + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// TestRubricLLD4_ForgetFileMatchesEverySpelling: a baseline holds a host in +// the spelling its session's records named it in -- one spelling today, the +// call's own in a file written before -- and forget --host must find it from +// whichever spelling the user types. An exact key lookup left 10.1.2.3 in +// every baseline after `forget --host 167838211` and counted none cleared. +func TestRubricLLD4_ForgetFileMatchesEverySpelling(t *testing.T) { + for _, tc := range []struct{ name, held, asked string }{ + {"inet_aton decimal against today's key", "10.1.2.3", "167838211"}, + {"IPv4-mapped IPv6", "10.1.2.3", "[::ffff:10.1.2.3]"}, + {"expanded upper-case IPv6", "[2001:db8::1]", "[2001:DB8:0:0:0:0:0:1]"}, + {"a name with the root dot and a port", "pypi.org", "PyPI.org.:443"}, + {"a key from before one spelling", "167838211", "10.1.2.3"}, + } { + root := t.TempDir() + res := Update(root, "/a-project", "s1", time.UnixMilli(1_000), []string{tc.held, "kept.example"}) + if res.Err != nil { + t.Fatal(res.Err) + } + n, err := ForgetFile(res.Path, tc.asked) + if err != nil || n != 1 { + t.Errorf("%s: forget %q from a baseline holding %q cleared %d (%v); want 1", tc.name, tc.asked, tc.held, n, err) + continue + } + hosts := onDisk(t, root, "/a-project") + if _, ok := hosts[tc.held]; ok { + t.Errorf("%s: %q is still in the baseline after forgetting %q", tc.name, tc.held, tc.asked) + } + if _, ok := hosts["kept.example"]; !ok { + t.Errorf("%s: the forget took a host it was not asked for", tc.name) + } + } +} + +// TestRubricLLD4_ForgetFileRemovesAnUnreadableFileNamingAnySpelling: a file +// this build cannot parse is searched as bytes, and it holds hosts in the +// recorded spelling. It goes whole when it names the host in the spelling +// asked for or in the canonical one, and stays when it names neither. +func TestRubricLLD4_ForgetFileRemovesAnUnreadableFileNamingAnySpelling(t *testing.T) { + dir := filepath.Join(t.TempDir(), "baseline") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + body, asked string + cleared int + }{ + {`{"hosts":{"10.1.2.3":{`, "167838211", 1}, + {`{"hosts":{"167838211":{`, "167838211", 1}, + {`{"hosts":{"10.1.2.4":{`, "167838211", 0}, + } { + path := filepath.Join(dir, "x.json") + if err := os.WriteFile(path, []byte(tc.body), 0o600); err != nil { + t.Fatal(err) + } + n, err := ForgetFile(path, tc.asked) + _, statErr := os.Stat(path) + gone := os.IsNotExist(statErr) + if err != nil || n != tc.cleared || gone != (tc.cleared == 1) { + t.Errorf("forget %q from %s: cleared %d (%v), file gone %v; want %d", tc.asked, tc.body, n, err, gone, tc.cleared) + } + if err := os.RemoveAll(path); err != nil { + t.Fatal(err) + } + } +} + +// TestRubricLLD4_ForgetFileRefusesWhatIsNotAHost: an argument that names no +// host clears nothing and says so, rather than reading as a forget that ran. +func TestRubricLLD4_ForgetFileRefusesWhatIsNotAHost(t *testing.T) { + root := t.TempDir() + res := Update(root, "/a-project", "s1", time.UnixMilli(1_000), []string{"pypi.org"}) + if res.Err != nil { + t.Fatal(res.Err) + } + for _, bad := range []string{"deploy:secret@pypi.org", "[pypi.org]", " "} { + if _, err := ForgetFile(res.Path, bad); err == nil { + t.Errorf("forget %q was accepted: it names no host", bad) + } + } + if _, ok := onDisk(t, root, "/a-project")["pypi.org"]; !ok { + t.Error("a refused forget changed the baseline") + } +} diff --git a/internal/host/host.go b/internal/host/host.go index 1aba47ed..f4f18e3c 100644 --- a/internal/host/host.go +++ b/internal/host/host.go @@ -257,6 +257,20 @@ func Shaped(s string) bool { return alnum } +// Same reports whether a stored host and a canonical one name the same +// destination: equal as stored, or equal once the stored one is +// canonicalised. A stored host is canonical when this build wrote it, but a +// record or a baseline written before an IP literal had one spelling holds it +// as the call wrote it -- 167838211, [2606:4700:0:0:0:0:0:1111] -- and a +// forget that compared it as stored would leave it on disk. +func Same(stored, canonical string) bool { + if stored == canonical { + return true + } + c, ok := Canonical(stored) + return ok && c == canonical +} + // Loopback reports whether h names this machine: localhost or a name under // .localhost (RFC 6761 6.3), an address in 127.0.0.0/8 or ::1, or the // unspecified address, a connection to which reaches this machine. It is diff --git a/internal/store/gaps.go b/internal/store/gaps.go index 0b94d771..be443988 100644 --- a/internal/store/gaps.go +++ b/internal/store/gaps.go @@ -411,13 +411,11 @@ func countedLines(snapshot, kept [][]byte) map[string]int { return counted } -// canonicalHost is host.Canonical, under a name ForgetHost's parameter does -// not shadow. -func canonicalHost(raw string) (string, bool) { return host.Canonical(raw) } - -// ErrNotAHostname is ForgetHost's refusal of a value that names no host: a -// path, a credential, whitespace inside, or nothing left once a scheme and a -// port are removed. It does not carry the value, which may hold a credential. +// ErrNotAHostname is a host-scoped forget's refusal of a value that names no +// host: a path, a credential, whitespace inside, or nothing left once a scheme +// and a port are removed. host.Canonical refuses it, so no record can match +// it, and removing nothing must not read as a forget that worked. It does not +// carry the value, which may hold a credential. var ErrNotAHostname = errors.New("store: forget --host: the value is not a hostname") // declHead is what a host-scoped forget needs from a record: the join key and @@ -451,15 +449,15 @@ type declHead struct { // THE NAME IS CANONICALISED FIRST, by host.Canonical, the function every host // this store and the report hold has been through: the declarations' lists // and the sandbox's trail. Matched and digested as typed, "GitHub.com", -// "github.com:443" and "https://github.com/" each named a host nothing held, -// so the forget removed nothing and suppressed nothing while the command said -// later reports would leave it out. A value Canonical refuses names no -// destination at all and is refused here, before anything is written. -func (s *Store) ForgetHost(host string, now time.Time) ([]Gap, error) { - if host == "" { - return nil, errors.New("store: forget --host needs a host") - } - host, ok := canonicalHost(host) +// "github.com:443", "https://github.com/" and 167838211 (recorded as +// 10.1.2.3) each named a host nothing held, so the forget removed nothing and +// suppressed nothing while the command said later reports would leave it +// out. The gap's digest and the store-level record's are the canonical +// host's, the spelling the sandbox trail carries. A value Canonical refuses +// names no destination at all and is refused here, before anything is +// written. +func (s *Store) ForgetHost(raw string, now time.Time) ([]Gap, error) { + name, ok := host.Canonical(raw) if !ok { return nil, ErrNotAHostname } @@ -470,10 +468,10 @@ func (s *Store) ForgetHost(host string, now time.Time) ([]Gap, error) { // source this store never held -- the sandbox's trail -- and a forget // that wrote nothing when it removed nothing left the host listed there // (#45). - if err := s.recordForgottenHost(s.HostDigest(host), now); err != nil { + if err := s.recordForgottenHost(s.HostDigest(name), now); err != nil { return nil, err } - return s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, host, now) }) + return s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, name, now) }) } // recordForgottenHost appends the store-level forget record for a digest, @@ -533,7 +531,7 @@ func (s *Store) recordForgottenHost(digest string, now time.Time) error { return syncFull(f) } -func (s *Store) forgetHostInRun(dir, host string, now time.Time) (*Gap, error) { +func (s *Store) forgetHostInRun(dir, name string, now time.Time) (*Gap, error) { rl, err := openRunLines(dir) if err != nil { return nil, err @@ -558,7 +556,7 @@ func (s *Store) forgetHostInRun(dir, host string, now time.Time) (*Gap, error) { if h.Type != TypeDeclaration { continue } - if namesHost(h, host) { + if namesHost(h, name) { doomed[h.ToolUseID] = true } } @@ -595,7 +593,7 @@ func (s *Store) forgetHostInRun(dir, host string, now time.Time) (*Gap, error) { g := NewGap(GapForgetHost, sessionID, now) g.FromUnixMS, g.ToUnixMS = earliest, latest g.RemovedRecords = removedRec + removedSpill - g.HostDigest = s.HostDigest(host) + g.HostDigest = s.HostDigest(name) // The gap lands first, as it does for a window forget: a failure between // the two leaves a gap for records that still exist, an overstatement a // reader can see, rather than a deletion nothing admits to. @@ -616,11 +614,12 @@ func (s *Store) forgetHostInRun(dir, host string, now time.Time) (*Gap, error) { return &g, nil } -// namesHost reports whether a declaration named the host, in either list. -func namesHost(h declHead, host string) bool { +// namesHost reports whether a declaration named the canonical host name, in +// either list and in any spelling (host.Same). +func namesHost(h declHead, name string) bool { for _, list := range [][]string{h.Hosts, h.SSHHosts} { for _, got := range list { - if got == host { + if host.Same(got, name) { return true } } diff --git a/internal/store/rubric_lld4_forget_test.go b/internal/store/rubric_lld4_forget_test.go new file mode 100644 index 00000000..17477256 --- /dev/null +++ b/internal/store/rubric_lld4_forget_test.go @@ -0,0 +1,91 @@ +package store + +import ( + "testing" + "time" +) + +// forgetFixture is a store holding one declaration that named recorded, in +// hosts or, when ssh is set, in ssh_hosts. +func forgetFixture(t *testing.T, recorded string, ssh bool) *Store { + t.Helper() + st, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + d := Declaration{ + Type: TypeDeclaration, SchemaVersion: SchemaVersion, SessionID: "s-lld4", + ToolUseID: "toolu_lld4", ToolName: "Bash", RecordedAtMS: 1_000, + } + if ssh { + d.SSHHosts = []string{recorded} + } else { + d.Hosts = []string{recorded} + } + if err := st.AppendDeclaration(d); err != nil { + t.Fatal(err) + } + return st +} + +// TestRubricLLD4_ForgetHostMatchesEverySpelling: a host is recorded in its +// one spelling, so forget --host must ask in that spelling too, whatever the +// user typed. Recording `curl https://167838211/x` stores 10.1.2.3, and a +// forget that compared the raw argument removed nothing while the command +// read as if it had worked: the host the user asked to forget stayed on +// disk. Records written before addresses had one spelling hold them as the +// call wrote them, so each recorded host is canonicalised before the +// comparison as well. The gap's digest is the canonical host's, which is the +// spelling the sandbox trail's events carry, so the report suppresses it. +func TestRubricLLD4_ForgetHostMatchesEverySpelling(t *testing.T) { + for _, tc := range []struct { + name, recorded, asked, canonical string + ssh bool + }{ + {"inet_aton decimal against today's record", "10.1.2.3", "167838211", "10.1.2.3", false}, + {"inet_aton hex", "10.1.2.3", "0xa010203", "10.1.2.3", false}, + {"inet_aton octal and short", "10.1.2.3", "012.1.515", "10.1.2.3", false}, + {"IPv4-mapped IPv6", "10.1.2.3", "[::ffff:10.1.2.3]", "10.1.2.3", false}, + {"expanded upper-case IPv6", "[2001:db8::1]", "[2001:DB8:0:0:0:0:0:1]", "[2001:db8::1]", false}, + {"a name in another case, with the root dot and a port", "pypi.org", "PyPI.org.:443", "pypi.org", false}, + {"a URL", "pypi.org", "https://pypi.org/simple", "pypi.org", false}, + {"an ssh host", "10.1.2.3", "167838211", "10.1.2.3", true}, + {"a record from before one spelling, asked in the new one", "167838211", "10.1.2.3", "10.1.2.3", false}, + {"a record from before one spelling, asked as written", "[2606:4700:0:0:0:0:0:1111]", "[2606:4700:0:0:0:0:0:1111]", "[2606:4700::1111]", false}, + } { + st := forgetFixture(t, tc.recorded, tc.ssh) + gaps, err := st.ForgetHost(tc.asked, time.UnixMilli(2_000)) + if err != nil { + t.Fatalf("%s: forget --host %q: %v", tc.name, tc.asked, err) + } + if len(gaps) != 1 || gaps[0].RemovedRecords != 1 { + t.Errorf("%s: forget --host %q after recording %q removed %v; want one record in one run", + tc.name, tc.asked, tc.recorded, gaps) + continue + } + forgotten, err := st.ForgottenHost() + if err != nil { + t.Fatal(err) + } + if !forgotten(tc.canonical) { + t.Errorf("%s: the gap's digest is not %q's, so the sandbox trail keeps the host", tc.name, tc.canonical) + } + } +} + +// TestRubricLLD4_ForgetHostRefusesWhatIsNotAHost: an argument host.Canonical +// refuses names no host, so no record can be matched against it. Removing +// nothing and reporting success would read as a forget that worked; it is an +// error instead, and nothing is removed. +func TestRubricLLD4_ForgetHostRefusesWhatIsNotAHost(t *testing.T) { + for _, bad := range []string{"deploy:secret@pypi.org", "[pypi.org]", "pypi:org:x", "py pi.org", " ", "."} { + st := forgetFixture(t, "pypi.org", false) + if gaps, err := st.ForgetHost(bad, time.UnixMilli(2_000)); err == nil { + t.Errorf("forget --host %q was accepted (%d runs): it names no host", bad, len(gaps)) + } + gaps, err := st.ForgetHost("pypi.org", time.UnixMilli(3_000)) + if err != nil || len(gaps) != 1 { + t.Errorf("after refusing %q, forget --host pypi.org removed %v (%v); want the record still there", bad, gaps, err) + } + } +} diff --git a/test/mutation/mutants/H-25_forget-host-summary-before-error.py b/test/mutation/mutants/H-25_forget-host-summary-before-error.py index 6cf64b1b..07528e0f 100644 --- a/test/mutation/mutants/H-25_forget-host-summary-before-error.py +++ b/test/mutation/mutants/H-25_forget-host-summary-before-error.py @@ -3,10 +3,10 @@ # what ForgetHost had already removed. The summary now prints before either # error is returned. m("forgetHost returns a partial ForgetHost error before printing", "cmd/rashomon/main.go", - "\tgaps, forgetErr := st.ForgetHost(host, time.Now())", - "\tgaps, forgetErr := st.ForgetHost(host, time.Now())\n\tif forgetErr != nil {\n\t\treturn forgetErr\n\t}", + "\tgaps, forgetErr := st.ForgetHost(raw, time.Now())", + "\tgaps, forgetErr := st.ForgetHost(raw, time.Now())\n\tif forgetErr != nil {\n\t\treturn forgetErr\n\t}", "TestForgetHost_PrintsTheSummaryEvenWhenForgetHostFails") m("forgetHost returns a clearBaselines error before printing", "cmd/rashomon/main.go", - "\tcleared, hadBaselines, clearErr := clearBaselines(st.Root(), host)", - "\tcleared, hadBaselines, clearErr := clearBaselines(st.Root(), host)\n\tif clearErr != nil {\n\t\treturn clearErr\n\t}", + "\tcleared, hadBaselines, clearErr := clearBaselines(st.Root(), raw)", + "\tcleared, hadBaselines, clearErr := clearBaselines(st.Root(), raw)\n\tif clearErr != nil {\n\t\treturn clearErr\n\t}", "TestForgetHost_PrintsTheSummaryEvenWhenClearBaselinesFails") diff --git a/test/mutation/mutants/NONO_nono.py b/test/mutation/mutants/NONO_nono.py index 99c8bd5e..116d6297 100644 --- a/test/mutation/mutants/NONO_nono.py +++ b/test/mutation/mutants/NONO_nono.py @@ -111,8 +111,8 @@ "\t\tif false && json.Unmarshal(line, &r) == nil {", "TestForgetHost_AHost|TestSeam_AHostOnlyTheSandboxSaw") m("NONO the store-level record holds the hostname", "internal/store/gaps.go", - "\tif err := s.recordForgottenHost(s.HostDigest(host), now); err != nil {", - "\tif err := s.recordForgottenHost(s.HostDigest(host)+host, now); err != nil {", + "\tif err := s.recordForgottenHost(s.HostDigest(name), now); err != nil {", + "\tif err := s.recordForgottenHost(s.HostDigest(name)+name, now); err != nil {", "TestForgetHost_TheStoreLevelRecordHoldsNoName") m("NONO forget --host does not say a no-op forget still holds", "cmd/rashomon/main.go", "\tif total == 0 {\n\t\tfmt.Fprint(stdout, \"; later reports", "\tif false {\n\t\tfmt.Fprint(stdout, \"; later reports", @@ -123,17 +123,25 @@ # github.com:443, https://github.com/ -- forgot nothing while the line said # later reports would leave it out. m("NONO the store forgets the host as typed", "internal/store/gaps.go", - "\thost, ok := canonicalHost(host)\n", "\tok := host != \"\"\n", + "\tname, ok := host.Canonical(raw)\n\tif !ok {\n\t\treturn nil, ErrNotAHostname\n\t}\n", + "\tname, ok := raw, raw != \"\"\n\tif !ok {\n\t\treturn nil, ErrNotAHostname\n\t}\n", "TestForgetHost_AnySpelling|TestSeam_AnySpelling") m("NONO the store forgets a value that is not a hostname", "internal/store/gaps.go", "\tif !ok {\n\t\treturn nil, ErrNotAHostname\n\t}\n", "\tif !ok && false {\n\t\treturn nil, ErrNotAHostname\n\t}\n", "TestForgetHost_WhatIsNot") +# Re-anchored when the rubric's lld-4 fix met this one: forget --host takes the +# value as typed and canonicalises it once, in forgetHost, which still refuses +# a non-host before anything is opened; the store and the baselines +# canonicalise again for their library callers, so the store's own refusal +# would hide a dropped command-line check from the H25 test alone. m("NONO forget --host passes the value on as typed", "cmd/rashomon/main.go", - "\t\t\ttarget = h\n", "\t\t\ttarget, _ = args[i+1], h\n", + "\tname, ok := host.Canonical(raw)\n\tif !ok {\n\t\treturn errors.New(\"--host is not a hostname", + "\t_, ok := host.Canonical(raw)\n\tname := raw\n\tif !ok {\n\t\treturn errors.New(\"--host is not a hostname", "TestH25_ForgetHostTakesAnySpelling") m("NONO forget --host accepts what is not a hostname", "cmd/rashomon/main.go", - "\t\t\th, ok := host.Canonical(args[i+1])\n\t\t\tif !ok {", "\t\t\th, ok := host.Canonical(args[i+1])\n\t\t\tif false && !ok {", - "TestH25_ForgetHostRefusesWhatIs") + "\tname, ok := host.Canonical(raw)\n\tif !ok {\n\t\treturn errors.New(\"--host is not a hostname", + "\tname, ok := host.Canonical(raw)\n\tif false && !ok {\n\t\treturn errors.New(\"--host is not a hostname", + "TestH25_ForgetHostRefusesWhatIs|TestRubricLLD4_ForgetCommandAsksInTheRecordedSpelling") # A client-plane host one of the session's own tool calls named is the # agent's attempt (review of #53): filed as the client's, a refused curl to the # Datadog log intake left "refused by the sandbox". @@ -185,16 +193,16 @@ # error paths is swallowed (review of #53): each of these survived every # related test. m("NONO the store-level forget record lands after the runs", "internal/store/gaps.go", - "\tif err := s.recordForgottenHost(s.HostDigest(host), now); err != nil {\n\t\treturn nil, err\n\t}\n\treturn s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, host, now) })", - "\tgaps, err := s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, host, now) })\n\tif err != nil {\n\t\treturn gaps, err\n\t}\n\tif err := s.recordForgottenHost(s.HostDigest(host), now); err != nil {\n\t\treturn nil, err\n\t}\n\treturn gaps, nil", + "\tif err := s.recordForgottenHost(s.HostDigest(name), now); err != nil {\n\t\treturn nil, err\n\t}\n\treturn s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, name, now) })", + "\tgaps, err := s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, name, now) })\n\tif err != nil {\n\t\treturn gaps, err\n\t}\n\tif err := s.recordForgottenHost(s.HostDigest(name), now); err != nil {\n\t\treturn nil, err\n\t}\n\treturn gaps, nil", "TestForgetHost_AForgetThatFails") m("NONO an unreadable store-level forget file reads as nothing forgotten", "internal/store/gaps.go", "\ttimes, err := s.forgottenDigestTimes()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(times) == 0 {\n\t\t// A predicate", "\ttimes, err := s.forgottenDigestTimes()\n\t_ = err\n\tif len(times) == 0 {\n\t\t// A predicate", "TestForgetHost_AnUn") m("NONO a forget whose store-level record failed reports success", "internal/store/gaps.go", - "\tif err := s.recordForgottenHost(s.HostDigest(host), now); err != nil {\n\t\treturn nil, err\n\t}\n", - "\t_ = s.recordForgottenHost(s.HostDigest(host), now)\n", + "\tif err := s.recordForgottenHost(s.HostDigest(name), now); err != nil {\n\t\treturn nil, err\n\t}\n", + "\t_ = s.recordForgottenHost(s.HostDigest(name), now)\n", "TestForgetHost_AnUn") # The client-plane rule has one spelling, clientPlaneHost, and every view diff --git a/test/mutation/mutants/REC-32_reader-limits.py b/test/mutation/mutants/REC-32_reader-limits.py index c2952937..2401a553 100644 --- a/test/mutation/mutants/REC-32_reader-limits.py +++ b/test/mutation/mutants/REC-32_reader-limits.py @@ -30,8 +30,8 @@ m("REC-32 the report prints an unknown count as -1", "internal/report/text.go", "\t\t\tif g.RemovedRecords < 0 {\n", "\t\t\tif false {\n", "TestREC32c_") m("REC-32 forget --host prints nothing when one run fails", "cmd/rashomon/main.go", - "\tgaps, forgetErr := st.ForgetHost(host, time.Now())\n", - "\tgaps, forgetErr := st.ForgetHost(host, time.Now())\n\tif forgetErr != nil {\n\t\treturn forgetErr\n\t}\n", "TestREC32e_") + "\tgaps, forgetErr := st.ForgetHost(raw, time.Now())\n", + "\tgaps, forgetErr := st.ForgetHost(raw, time.Now())\n\tif forgetErr != nil {\n\t\treturn forgetErr\n\t}\n", "TestREC32e_") m("REC-32 forget --host succeeds while a run still holds the host", "cmd/rashomon/main.go", "\tfmt.Fprintln(stdout)\n\treturn errors.Join(forgetErr, clearErr)\n", "\tfmt.Fprintln(stdout)\n\treturn clearErr\n", "TestREC32e_") m("REC-32 the ordered append goes on after a torn tail", "internal/store/store.go", @@ -40,23 +40,23 @@ # A forget --host that failed before touching a run, its store-level record # included, prints no line: "later reports still leave it out" would be false. m("REC-32 forget --host reports a forget that failed before any run", "cmd/rashomon/main.go", - "\tgaps, forgetErr := st.ForgetHost(host, time.Now())\n\tif forgetErr != nil && !errors.Is(forgetErr, store.ErrForgetIncomplete) {", - "\tgaps, forgetErr := st.ForgetHost(host, time.Now())\n\tif false && forgetErr != nil && !errors.Is(forgetErr, store.ErrForgetIncomplete) {", + "\tgaps, forgetErr := st.ForgetHost(raw, time.Now())\n\tif forgetErr != nil && !errors.Is(forgetErr, store.ErrForgetIncomplete) {", + "\tgaps, forgetErr := st.ForgetHost(raw, time.Now())\n\tif false && forgetErr != nil && !errors.Is(forgetErr, store.ErrForgetIncomplete) {", "TestREC32f_") # Nor does it clear a baseline: the error would be the only thing said, and the # command would have changed what the next report calls new. m("REC-32 forget --host clears baselines after a forget that failed before any run", "cmd/rashomon/main.go", "\t\t// command changed nothing it does not say.\n\t\treturn forgetErr\n", - "\t\t// command changed nothing it does not say.\n\t\t_, _, _ = clearBaselines(st.Root(), host)\n\t\treturn forgetErr\n", + "\t\t// command changed nothing it does not say.\n\t\t_, _, _ = clearBaselines(st.Root(), raw)\n\t\treturn forgetErr\n", "TestForgetHost_AnEarlyFailureClearsNoBaselineAndPrintsNothing") # A partial forget prints "later reports still leave it out", which the # store-level record makes true: it must land before the pass over the runs # that can end in ErrForgetIncomplete, not only after a pass that finished. m("REC-32 forget --host records the host only after every run was rewritten", "internal/store/gaps.go", - "\tif err := s.recordForgottenHost(s.HostDigest(host), now); err != nil {\n\t\treturn nil, err\n\t}\n" - "\treturn s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, host, now) })\n", - "\tgaps, err := s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, host, now) })\n" - "\tif err != nil {\n\t\treturn gaps, err\n\t}\n\treturn gaps, s.recordForgottenHost(s.HostDigest(host), now)\n", + "\tif err := s.recordForgottenHost(s.HostDigest(name), now); err != nil {\n\t\treturn nil, err\n\t}\n" + "\treturn s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, name, now) })\n", + "\tgaps, err := s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, name, now) })\n" + "\tif err != nil {\n\t\treturn gaps, err\n\t}\n\treturn gaps, s.recordForgottenHost(s.HostDigest(name), now)\n", "TestForgetHost_APartialForgetSaysLaterReportsLeaveItOutOnlyWithItsRecord") m("REC-32 eviction stops at a run it cannot list", "internal/store/evict.go", "\t\t\tplan.unlisted = append(plan.unlisted, fmt.Errorf(\"run %s: %w\", e.Name(), err))\n\t\t\tcontinue\n", diff --git a/test/mutation/mutants/RUBRIC_hld-2_baseline-lock.py b/test/mutation/mutants/RUBRIC_hld-2_baseline-lock.py index 795939ae..456a7a11 100644 --- a/test/mutation/mutants/RUBRIC_hld-2_baseline-lock.py +++ b/test/mutation/mutants/RUBRIC_hld-2_baseline-lock.py @@ -6,7 +6,7 @@ "\trelease, err := store.LockPath(filepath.Join(dir, lockName))\n\tif err != nil {\n\t\treturn Result{}, err\n\t}\n\tdefer release()\n", "", "TestRubricHLD2_ConcurrentRendersLoseNoHost") m("RUBRIC hld-2 a forget rewrites after releasing the lock", "internal/baseline/baseline.go", - "\tdefer release()\n\treturn forgetLocked(path, host)\n", "\trelease()\n\treturn forgetLocked(path, host)\n", + "\tdefer release()\n\treturn forgetLocked(path, raw, name)\n", "\trelease()\n\treturn forgetLocked(path, raw, name)\n", "TestRubricHLD2_ForgetAndRenderLoseNoChange") m("RUBRIC hld-2 the fold edits the file it was handed", "internal/baseline/fold.go", "\tout.Hosts = maps.Clone(f.Hosts)\n", "\tout.Hosts = f.Hosts\n", "TestRubricHLD2_FoldIsPure") diff --git a/test/mutation/mutants/RUBRIC_lld-4_spellings.py b/test/mutation/mutants/RUBRIC_lld-4_spellings.py index c6a0dd9f..0a6ee4f1 100644 --- a/test/mutation/mutants/RUBRIC_lld-4_spellings.py +++ b/test/mutation/mutants/RUBRIC_lld-4_spellings.py @@ -24,3 +24,28 @@ "TestRubricLLD4_") IMPORTS["RUBRIC lld-4 the report filters loopback by its old spellings"] = ("internal/report/destinations.go", "\t\"github.com/altrace-dev-role/rashomon/internal/host\"\n", "") +# forget --host asks in the recorded spelling (fix list 30, the forget half): +# `curl https://167838211/x` records 10.1.2.3, and a forget that compared the +# typed value, or a recorded one as stored, left the host on disk while +# reading as done; a value that names no host must be refused, not forgotten +# as nothing. +m("RUBRIC lld-4 forget --host compares the typed value", "internal/store/gaps.go", + "\tname, ok := host.Canonical(raw)\n\tif !ok {\n\t\treturn nil, ErrNotAHostname\n\t}\n", + "\tname, ok := raw, raw != \"\"\n\tif !ok {\n\t\treturn nil, ErrNotAHostname\n\t}\n", "TestRubricLLD4_") +m("RUBRIC lld-4 a value naming no host is forgotten as nothing", "internal/store/gaps.go", + "\t\treturn nil, ErrNotAHostname\n", "", "TestRubricLLD4_") +m("RUBRIC lld-4 a stored host is compared only as stored", "internal/host/host.go", + "\tc, ok := Canonical(stored)\n\treturn ok && c == canonical\n", "\treturn false\n", "TestRubricLLD4_") +m("RUBRIC lld-4 a baseline is asked in the typed spelling", "internal/baseline/baseline.go", + "\tname, ok := host.Canonical(raw)\n\tif !ok {\n\t\treturn 0, store.ErrNotAHostname\n\t}\n", + "\tname, ok := raw, raw != \"\"\n\tif !ok {\n\t\treturn 0, store.ErrNotAHostname\n\t}\n", "TestRubricLLD4_") +m("RUBRIC lld-4 a baseline forget of no host reads as done", "internal/baseline/baseline.go", + "\t\treturn 0, store.ErrNotAHostname\n", "\t\treturn 0, nil\n", "TestRubricLLD4_") +m("RUBRIC lld-4 an unparsed baseline is searched for the typed spelling only", "internal/baseline/baseline.go", + "\t\tif !namesHost(body, name) && !namesHost(body, raw) {\n", "\t\tif !namesHost(body, raw) {\n", "TestRubricLLD4_") +m("RUBRIC lld-4 the forget summary names the typed value", "cmd/rashomon/main.go", + "\tname, ok := host.Canonical(raw)\n\tif !ok {\n\t\treturn errors.New(\"--host is not a hostname", + "\t_, ok := host.Canonical(raw)\n\tname := raw\n\tif !ok {\n\t\treturn errors.New(\"--host is not a hostname", "TestRubricLLD4_") +m("RUBRIC lld-4 the command opens the store before asking whether the value is a host", "cmd/rashomon/main.go", + "\tname, ok := host.Canonical(raw)\n\tif !ok {\n\t\treturn errors.New(\"--host is not a hostname", + "\tname, ok := host.Canonical(raw)\n\tif st, err := openStoreForRead(); errors.Is(err, store.ErrNoStore) && st == nil {\n\t\tfmt.Fprintln(stdout, nothingRecordedHere)\n\t\treturn nil\n\t}\n\tif !ok {\n\t\treturn errors.New(\"--host is not a hostname", "TestRubricLLD4_") diff --git a/test/mutation/mutants/TRU-58_baseline-basis.py b/test/mutation/mutants/TRU-58_baseline-basis.py index ae535ace..59d0abbd 100644 --- a/test/mutation/mutants/TRU-58_baseline-basis.py +++ b/test/mutation/mutants/TRU-58_baseline-basis.py @@ -18,9 +18,9 @@ # naming it again would never be told it is new. Judged by TRU-58b alone: its # third session names the host after the forget. m("TRU-58 forget keeps the host in a current baseline", "internal/baseline/baseline.go", - "\t\tdelete(f.Hosts, host)\n", "", "TestTRU58b_") + "\t\t\tdelete(f.Hosts, h)\n", "", "TestTRU58b_") m("TRU-58 forget clears no baseline at all", "internal/baseline/baseline.go", - "\tif path == \"\" || host == \"\" {\n\t\treturn 0, nil\n\t}\n", "\tif true {\n\t\treturn 0, nil\n\t}\n", + "\tif path == \"\" {\n\t\treturn 0, nil\n\t}\n", "\tif true {\n\t\treturn 0, nil\n\t}\n", "TestTRU58b_") # TRU-58c: a baseline this build cannot parse, or of a version it does not # know, cannot be rewritten without the host. Holding the host, it is deleted @@ -28,7 +28,7 @@ m("TRU-58 forget leaves an unparseable baseline that holds the host", "internal/baseline/baseline.go", "\t\treturn removeWhole(path)\n", "\t\treturn 0, nil\n", "TestTRU58c_") m("TRU-58 forget deletes an unparseable baseline that does not hold the host", "internal/baseline/baseline.go", - "\t\tif !namesHost(body, host) {\n", "\t\tif false && !namesHost(body, host) {\n", "TestTRU58_") + "\t\tif !namesHost(body, name) && !namesHost(body, raw) {\n", "\t\tif false {\n", "TestTRU58_") # Round two. An unreadable baseline may hold the host, so it is an error and # not a zero; one file the forget cannot clear must not stop it clearing the # others; and the byte search matches the whole quoted host, not any run of From e8f2dd6c69c19d8c072ed5c7242b3624d0797b30 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 14:45:04 -0700 Subject: [PATCH 20/50] cmd/rashomon: pin the hook's exit, deadline and peak rss on pathological lines RES-29 names its test TestRES29_PathologicalInputsStayBounded, a table in which each case exits 0 inside its deadline at peak RSS of at most 64 MiB. The branch had only the shape package's allocation bounds on Derive, LeadingDirectory and Hosts, and the nesting test. Nothing pinned the hook entry's exit status or its RSS, and the "58 MiB or less" figure in hook.MaxPayloadBytes' comment came from runs by hand under /usr/bin/time. The test now exists under the spec's name. It runs the hook entry in a child process against a started session, as the installed binary runs it, on RES-29's rows that the shape of a call owns: 8 MiB of short words (the first row, which the RES-9 mutant turns red), 8 MiB of `<`, a 1 MB `cd a&&` and nesting one past maxNest. It also runs every other 8 MiB line shape the shape package bounds. Each row must exit 0 with nothing printed, within the hook's 4 s hard stop and 64 MiB of peak RSS, and must leave the call recorded once with verified coverage, so a hook that stood down early cannot pass. On darwin the highest peak over three runs was 59.5 MiB (the here-document and the substitutions). With the token bound lifted, short words peaked at 703 MiB and `<` at 815 MiB. The line shapes move into test/perf/pathological, a package with no imports, so the shape package's allocation test and this one read one table. test/perf/payload could not hold it, because it imports internal/shape. The bench helpers benchSetenv, benchInstall and benchStarted take testing.TB so the test can start a session with them. RES-29's other rows are pinned beside their own entries or wait for the parts that read them; the test's comment names each one. The two "58 MiB" statements now point at the test. Mutant: RES-29_pathological.py, the token bound lifted, judged by TestRES29_PathologicalInputsStayBounded. Every other RES-29 mutant is still red with the shape test reading the shared table. Spec P-12 row RES-29. CWE-400, CWE-770. --- cmd/rashomon/hook_bench_test.go | 6 +- cmd/rashomon/res29_test.go | 143 +++++++++++++++++++ internal/hook/handle.go | 4 +- internal/shape/res29_alloc_test.go | 46 +++--- test/mutation/mutants/RES-29_pathological.py | 8 ++ test/perf/pathological/pathological.go | 40 ++++++ 6 files changed, 215 insertions(+), 32 deletions(-) create mode 100644 cmd/rashomon/res29_test.go create mode 100644 test/mutation/mutants/RES-29_pathological.py create mode 100644 test/perf/pathological/pathological.go diff --git a/cmd/rashomon/hook_bench_test.go b/cmd/rashomon/hook_bench_test.go index 27ae5100..47746158 100644 --- a/cmd/rashomon/hook_bench_test.go +++ b/cmd/rashomon/hook_bench_test.go @@ -201,7 +201,7 @@ func (s *benchSink) lastWrite() []byte { return s.last[:s.n] } // benchSetenv points the store and Claude Code's configuration at scratch // directories, so nothing here reads or writes the real ones. -func benchSetenv(b *testing.B, root, cfg string) { +func benchSetenv(b testing.TB, root, cfg string) { b.Setenv("RASHOMON_HOME", root) b.Setenv("CLAUDE_CONFIG_DIR", cfg) b.Setenv("RASHOMON_MANAGED_SETTINGS_PATH", filepath.Join(cfg, "absent")) @@ -211,7 +211,7 @@ func benchSetenv(b *testing.B, root, cfg string) { // settings entries watch writes, so coverage resolves the entry as present: // the healthy path. watch itself cannot run here, because install.Executable // refuses a go-build binary. -func benchInstall(b *testing.B, root, cfg string) string { +func benchInstall(b testing.TB, root, cfg string) string { b.Helper() if err := payload.Pin(root); err != nil { b.Fatal(err) @@ -235,7 +235,7 @@ func benchInstall(b *testing.B, root, cfg string) string { } // benchStarted is a store with the session started as SessionStart starts it. -func benchStarted(b *testing.B) (root, installID string) { +func benchStarted(b testing.TB) (root, installID string) { b.Helper() root, cfg := b.TempDir(), b.TempDir() benchSetenv(b, root, cfg) diff --git a/cmd/rashomon/res29_test.go b/cmd/rashomon/res29_test.go new file mode 100644 index 00000000..a1de0b18 --- /dev/null +++ b/cmd/rashomon/res29_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "bytes" + "errors" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/altrace-dev-role/rashomon/internal/install" + "github.com/altrace-dev-role/rashomon/internal/store" + "github.com/altrace-dev-role/rashomon/test/perf/pathological" + "github.com/altrace-dev-role/rashomon/test/perf/peakrss" +) + +const res29Child = "RASHOMON_RES29_CHILD" + +// res29Payload is a PreToolUse payload for a Bash call whose command is +// prefix, then unit repeated as many times as fit, then suffix, so that the +// whole payload is at most size bytes. The command is encoded as Claude Code +// encodes it, & < > unescaped; a unit costs its encoded bytes, which for a +// backslash, a newline or a quote is more than its own. +func res29Payload(t *testing.T, size int, prefix, unit, suffix string) []byte { + t.Helper() + pre := func(cmd string) []byte { + return benchToolCall(t, "PreToolUse", "Bash", benchJSON(t, map[string]string{"command": cmd}), nil) + } + per := len(pre(unit)) - len(pre("")) + n := (size - len(pre(prefix+suffix))) / per + body := pre(prefix + strings.Repeat(unit, n) + suffix) + if len(body) > size || len(body) <= size-per { + t.Fatalf("payload is %d bytes, want at most %d and within one unit of it", len(body), size) + } + return body +} + +// TestRES29_PathologicalInputsStayBounded: each pathological input the shape +// of a call reads exits 0 inside the hook's hard stop at peak RSS of at most +// 64 MiB, and still records the call. It runs the hook entry in a child +// process, against a started session, as the installed binary runs it. +// +// The rows are RES-29's that the shape of a call owns -- 8 MiB of short words +// (the token bound, RES-9; the first row, which the RES-9 mutant turns red), +// 8 MiB of `<`, a 1 MB `cd a&&` (the leading-cd fold, RES-10) and nesting one +// past maxNest (32 levels; 17 quoted substitutions are 34) -- and every other +// 8 MiB line shape in pathological.Lines, so the hook's measured peak on each +// is pinned rather than taken by hand. The table's other rows are pinned +// beside their own entries: the 7 MB final message by +// TestRES7_HugeFinalMessageIsConstant; the 64 MiB PostToolBatch, the 187 MB +// transcript line, the 1 MiB file before a Write, the masker's bound on +// X_TOKEN assignments, the untrusted spec and the 4,096-byte ids by the parts +// that read them, which this tree does not have yet. internal/shape's +// TestRES29_ tests bound each reading of the same shapes by allocation, which +// a peak RSS cannot attribute. +func TestRES29_PathologicalInputsStayBounded(t *testing.T) { + if args := os.Getenv(res29Child); args != "" { + os.Exit(cmdHook(strings.Fields(args), os.Stdin, os.Stderr)) + } + if testing.Short() { + t.Skip("runs the hook in child processes on 8 MiB payloads") + } + const ( + deadline = 4 * time.Second // the hook's hard stop (85.3.10) + rssMiB = 64 + ) + type row struct { + name string + payload func(*testing.T) []byte + } + var rows []row + for _, l := range pathological.Lines() { + rows = append(rows, row{"8 MiB of " + l.Name, func(t *testing.T) []byte { + return res29Payload(t, 8<<20, l.Prefix, l.Unit, l.Suffix) + }}) + } + nested := `cd "` + strings.Repeat(`$(echo "`, 17) + "x" + strings.Repeat(`")`, 17) + `" && ls` + rows = append(rows, + row{"a 1 MB cd a&&", func(t *testing.T) []byte { return res29Payload(t, 1_000_000, "", "cd a&&", "") }}, + row{"nesting one past maxNest", func(t *testing.T) []byte { return res29Payload(t, len(nested)+1024, nested, " ", "") }}, + ) + if rows[0].name != "8 MiB of short words" || rows[7].name != "8 MiB of redirections" { + t.Fatalf("premise: the table's first row is %q and its eighth %q; RES-29 names short words and `<`", rows[0].name, rows[7].name) + } + // Each child runs under the reaper, so its reading is its own: on Linux a + // child's ru_maxrss is otherwise at least this test's peak, which holds + // the 8 MiB payload (test/perf/peakrss). A platform with no ru_maxrss + // (windows) still runs every row for its exit, deadline and record, and + // applies no RSS bound. + reaper, err := peakrss.NewReaper(t.TempDir()) + if err != nil && !errors.Is(err, peakrss.ErrUnavailable) { + t.Fatal(err) + } + for _, tc := range rows { + t.Run(tc.name, func(t *testing.T) { + root, installID := benchStarted(t) + cmd := exec.Command(os.Args[0], "-test.run=^TestRES29_PathologicalInputsStayBounded$") + cmd.Env = append(os.Environ(), res29Child+"="+install.Marker+" "+installID) + cmd.Stdin = bytes.NewReader(tc.payload(t)) + var out bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &out + var ( + rss float64 + err error + ) + start := time.Now() + if reaper != nil { + rss, err = reaper.Run(cmd) + } else { + err = cmd.Run() + } + took := time.Since(start) + if err != nil || out.Len() != 0 { + t.Fatalf("hook: %v, output %q; want exit 0 and nothing printed", err, out.String()) + } + if took > deadline { + t.Errorf("hook took %v, past its %v hard stop", took, deadline) + } + switch { + case reaper == nil: + t.Log("this platform reports no peak RSS for a child; the bound is not applied") + case raceEnabled: + t.Logf("race detector on: %.2f MiB, the %d MiB bound is not applied to an instrumented child", rss, rssMiB) + case rss > rssMiB: + t.Errorf("hook peaked at %.2f MiB, over %d MiB", rss, rssMiB) + default: + t.Logf("hook: %v, peak RSS %.2f MiB", took.Round(time.Millisecond), rss) + } + st, err := store.OpenExisting(root) + if err != nil { + t.Fatal(err) + } + run, err := st.ReadRun(benchSessionID) + if err != nil { + t.Fatal(err) + } + if got := benchCounts(run); got != [5]int{1, 1, 0, 1, 0} { + t.Errorf("store holds %v declarations, terminals, executions, verified coverage and skipped lines; want the call recorded once", got) + } + }) + } +} diff --git a/internal/hook/handle.go b/internal/hook/handle.go index 2d2c2c52..1dc1affc 100644 --- a/internal/hook/handle.go +++ b/internal/hook/handle.go @@ -32,7 +32,9 @@ import ( // below stops at maxLeadingCd (RES-10). That is measured, not proved: on the // sixteen 8 MiB line shapes TestRES29_ShapeOfEveryPathologicalLineIsBounded // reads, no reader allocates more than five times the payload, and the hook -// peaked at 58 MiB of RSS or less on each shape tried. +// entry stays within RES-29's 64 MiB of RSS on each, which +// cmd/rashomon's TestRES29_PathologicalInputsStayBounded pins (59.5 MiB at +// most over three runs, the here-document and the substitutions). const MaxPayloadBytes = hookio.MaxPayloadBytes // maxLeadingCd bounds the leading-cd fold in startDirectory: see diff --git a/internal/shape/res29_alloc_test.go b/internal/shape/res29_alloc_test.go index d6bbce3c..aa45ed77 100644 --- a/internal/shape/res29_alloc_test.go +++ b/internal/shape/res29_alloc_test.go @@ -6,13 +6,15 @@ import ( "runtime" "strings" "testing" + + "github.com/altrace-dev-role/rashomon/test/perf/pathological" ) // pathologicalLines is the tool input of a Bash call whose line is one of -// each shape that has driven the shape of a call past its memory: one unit -// repeated, behind echo where the unit is not a command of its own, as many -// times as an 8 MiB hook payload holds. The input is encoded as Claude Code -// encodes it, without escaping &, < and > as \u0026 and the like. +// each shape that has driven the shape of a call past its memory +// (pathological.Lines), as many units of it as an 8 MiB hook payload holds. +// The input is encoded as Claude Code encodes it, without escaping &, < and > +// as \u0026 and the like. func pathologicalLines(t testing.TB) []struct { name string in json.RawMessage @@ -26,32 +28,19 @@ func pathologicalLines(t testing.TB) []struct { } return bytes.TrimSuffix(b.Bytes(), []byte("\n")) } - fill := func(prefix, unit, suffix string) json.RawMessage { - per := len(encode(unit)) - len(encode("")) - n := (8<<20 - len(encode(prefix+suffix))) / per - return encode(prefix + strings.Repeat(unit, n) + suffix) - } - return []struct { + var out []struct { name string in json.RawMessage - }{ - {"short words", fill("echo", " a", "")}, - {"backslash-newlines", fill("echo ", "a\\\n", "")}, - {"one word of $a", fill("echo ", "$a", "")}, - {"escaped blanks", fill("echo ", "\\ ", "")}, - {"quoted blanks", fill("echo ", "'' ", "")}, - {"double-quoted blanks", fill("echo ", `"" `, "")}, - {"one double-quoted word", fill("echo \"", "a", "\"")}, - {"redirections", fill("", "<", "")}, - {"cd a&&", fill("", "cd a&&", "")}, - {"a here-document", fill("python3 - <<'E'\n", "w1 w2 w3 w4 w5 w6 w7 w8 w9 w0\n", "E\n")}, - {"substitutions", fill("echo ", "$(a) ", "")}, - {"assignments", fill("", "X_TOKEN=abc ", "")}, - {"empty lines", fill("echo", "\n", "")}, - {"short lines", fill("echo", "\na", "")}, - {"ssh lines", fill("", "ssh a\n", "")}, - {"urls", fill("curl", " https://a.b/", "")}, } + for _, l := range pathological.Lines() { + per := len(encode(l.Unit)) - len(encode("")) + n := (8<<20 - len(encode(l.Prefix+l.Suffix))) / per + out = append(out, struct { + name string + in json.RawMessage + }{l.Name, encode(l.Prefix + strings.Repeat(l.Unit, n) + l.Suffix)}) + } + return out } // allocated is how many bytes f allocates. @@ -77,7 +66,8 @@ func allocated(f func()) uint64 { // after https:// cost a parse error. And each reader decoded the command // into a buffer grown from nothing and copied it again. With the hook's own // intake those drove a hook to 87, 84 and 178 MiB of RSS against RES-29's -// 64; every shape here now peaks at 58 or less. +// 64; cmd/rashomon's TestRES29_PathologicalInputsStayBounded now runs the +// hook on every shape here and pins it within 64. func TestRES29_ShapeOfEveryPathologicalLineIsBounded(t *testing.T) { for _, tc := range pathologicalLines(t) { if len(tc.in) > 8<<20 { diff --git a/test/mutation/mutants/RES-29_pathological.py b/test/mutation/mutants/RES-29_pathological.py new file mode 100644 index 00000000..513c6d9e --- /dev/null +++ b/test/mutation/mutants/RES-29_pathological.py @@ -0,0 +1,8 @@ +# RES-29: each pathological input the shape of a call reads exits 0 inside +# the hook's hard stop at peak RSS of at most 64 MiB, through the hook entry +# in a child process. The RES-9 mutant turns the first row red (the spec's +# own pairing): with the token bound lifted, 8 MiB of short words is lexed +# whole. +m("RES-29 the token bound is lifted under the hook", "internal/shape/tokenize.go", + "\tmaxTokens = shapeBudget / bytesPerToken\n", "\tmaxTokens = 1 << 30\n", + "TestRES29_PathologicalInputsStayBounded") diff --git a/test/perf/pathological/pathological.go b/test/perf/pathological/pathological.go new file mode 100644 index 00000000..f8e9a5c2 --- /dev/null +++ b/test/perf/pathological/pathological.go @@ -0,0 +1,40 @@ +// Package pathological is the set of Bash command lines that have driven the +// shape of a call past its memory, shared by the tests that bound them at two +// levels: internal/shape's, which bound each reading of a line by allocation, +// and cmd/rashomon's, which run the hook entry on each one and bound its peak +// RSS (RES-29). One table, so a shape added at one level is measured at both. +// +// It imports nothing: internal/shape's own tests import it, and anything that +// imported shape would be a cycle. +package pathological + +// Line is a command line built as Prefix, then Unit repeated as many times as +// the caller's size allows, then Suffix. A unit holding a backslash, a newline +// or a quote costs more encoded than its length, so a caller sizing a payload +// measures a unit's encoded cost rather than counting its bytes. +type Line struct { + Name, Prefix, Unit, Suffix string +} + +// Lines is every shape, each one unit repeated behind echo where the unit is +// not a command of its own. +func Lines() []Line { + return []Line{ + {"short words", "echo", " a", ""}, + {"backslash-newlines", "echo ", "a\\\n", ""}, + {"one word of $a", "echo ", "$a", ""}, + {"escaped blanks", "echo ", "\\ ", ""}, + {"quoted blanks", "echo ", "'' ", ""}, + {"double-quoted blanks", "echo ", `"" `, ""}, + {"one double-quoted word", "echo \"", "a", "\""}, + {"redirections", "", "<", ""}, + {"cd a&&", "", "cd a&&", ""}, + {"a here-document", "python3 - <<'E'\n", "w1 w2 w3 w4 w5 w6 w7 w8 w9 w0\n", "E\n"}, + {"substitutions", "echo ", "$(a) ", ""}, + {"assignments", "", "X_TOKEN=abc ", ""}, + {"empty lines", "echo", "\n", ""}, + {"short lines", "echo", "\na", ""}, + {"ssh lines", "", "ssh a\n", ""}, + {"urls", "curl", " https://a.b/", ""}, + } +} From a36c8a38c7eecef79949773d7a5e977f95b64644 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Tue, 13 Oct 2026 15:27:34 -0700 Subject: [PATCH 21/50] shape: report a line cut at the token bound or the cd fold Derive and Hosts read a shell line only up to the token bound (RES-9), and Derive's leading-cd fold stops at MaxLeadingCd (RES-10). What followed the cut was dropped and nothing said so. An ssh, scp, sftp or rsync destination named past the bound was missing from ssh_hosts, and the short list read as complete. DeriveCut and HostsCut return the same shape and hosts, plus whether the line was cut. Derive reports a cut when either reading of the line stopped at the token bound, or when the line had more leading cd steps than the fold reads. Hosts reports a cut when the ssh destination reader stopped at the bound, either inside one line or with lines left over once its budget ran out. The wire list is never cut, because the URL scan reads the whole text. Derive and Hosts keep their signatures and drop the flag. The hook writes v4 records, which have no field for it. Schema 5's shape.truncated (Archive 85.3.10) is the OR of the two flags, and P-11 serialises it. Tests: TestRES9_DeriveSaysALineWasCut, TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut (line budget at N and N+1), TestRES10_DeriveSaysTheCdFoldWasCut. There are five new mutants. The H-70 mutant is re-anchored on the renamed merge line. --- internal/shape/hosts.go | 45 ++++++++---- internal/shape/res10_cut_test.go | 20 ++++++ internal/shape/res9_cut_test.go | 82 ++++++++++++++++++++++ internal/shape/shape.go | 20 +++++- test/mutation/mutants/H-70_hosts.py | 2 +- test/mutation/mutants/RES-10_leading-cd.py | 6 ++ test/mutation/mutants/RES-9_token-bound.py | 19 +++++ 7 files changed, 179 insertions(+), 15 deletions(-) create mode 100644 internal/shape/res10_cut_test.go create mode 100644 internal/shape/res9_cut_test.go diff --git a/internal/shape/hosts.go b/internal/shape/hosts.go index 55397685..3ccd0095 100644 --- a/internal/shape/hosts.go +++ b/internal/shape/hosts.go @@ -45,6 +45,20 @@ var sshSchemes = []string{"ssh://", "git+ssh://", "git@"} // some MCP server's unrelated schema ends up recorded as a destination this // session reached. func Hosts(toolName string, toolInput json.RawMessage) (wire []string, ssh []string) { + // A record below schema 5 has no field for the cut, and the hook writes + // those: see HostsCut, which is what a writer that can carry it calls. + wire, ssh, _ = HostsCut(toolName, toolInput) + return wire, ssh +} + +// HostsCut is Hosts, and whether the ssh list is short because the command +// was read only to the token bound (RES-9): an ssh, scp, sftp or rsync +// destination named past it is not read, so the list holds what came before +// the cut and nothing says the rest is absent. The wire list is never cut -- +// the URL scan reads the whole text -- and neither list is for WebFetch. It +// is the hosts half of schema 5's shape.truncated (Archive 85.3.10), which +// P-11 serialises; a v4 record does not carry it. +func HostsCut(toolName string, toolInput json.RawMessage) (wire, ssh []string, cut bool) { var text string switch { case toolName == "WebFetch": @@ -57,15 +71,17 @@ func Hosts(toolName string, toolInput json.RawMessage) (wire []string, ssh []str } } if text == "" { - return nil, nil + return nil, nil, false } // One lowered copy serves both readings of the text: see collect. lower := strings.ToLower(text) ssh = collect(lower, sshSchemes) if verbForTool(toolName) == VerbExecute { - ssh = mergeHosts(ssh, sshCommandHosts(text)) + var named []string + named, cut = sshCommandHosts(text) + ssh = mergeHosts(ssh, named) } - return collect(lower, wireSchemes), ssh + return collect(lower, wireSchemes), ssh, cut } // mergeHosts unions two host lists, sorted and de-duplicated, preserving the @@ -156,9 +172,9 @@ func takesValue(prog string, flag byte) bool { // costing at least one: tokenized one by one, each line was within the bound // and the line as a whole was not, and 8 MiB of `ssh a` lines allocated // 452 MiB. A host named past the bound is not read, as the program search -// reads no word past it. -func sshCommandHosts(text string) []string { - var out []string +// reads no word past it, and cut says so: a line read only to the bound, or +// lines left when the budget ran out. +func sshCommandHosts(text string) (out []string, cut bool) { // A newline separates two commands, and the tokenizer treats it as // whitespace -- deliberately, because Derive counts arguments over the // whole line and that count is its own contract. Splitting here rather @@ -167,18 +183,23 @@ func sshCommandHosts(text string) []string { for rest, budget := text, maxTokens; rest != "" && budget > 0; { var line string line, rest, _ = strings.Cut(rest, "\n") - hosts, read := sshLineHosts(line) + hosts, read, lineCut := sshLineHosts(line) out = append(out, hosts...) budget -= max(read, 1) + if lineCut || budget <= 0 && rest != "" { + cut = true + } } - return out + return out, cut } -// sshLineHosts is sshCommandHosts over one line, and how many tokens it read. -func sshLineHosts(line string) ([]string, int) { +// sshLineHosts is sshCommandHosts over one line, how many tokens it read, and +// whether it read the line only to the token bound. Any other tokenizer error +// is not a cut: the tokens it completed are the line's. +func sshLineHosts(line string) ([]string, int, bool) { // On a line it cannot finish, lex still returns the tokens it read before // the fault, and a host among them was named all the same. - toks, meta, _ := tokenizeMarked(line) + toks, meta, err := tokenizeMarked(line) var out []string atCommand := true @@ -211,7 +232,7 @@ func sshLineHosts(line string) ([]string, int) { out = append(out, destinations(prog, toks[i+1:end])...) i = end - 1 } - return out, len(toks) + return out, len(toks), err == errTokenBound } // destinations returns the hosts named by one ssh-family invocation's diff --git a/internal/shape/res10_cut_test.go b/internal/shape/res10_cut_test.go new file mode 100644 index 00000000..91d28f95 --- /dev/null +++ b/internal/shape/res10_cut_test.go @@ -0,0 +1,20 @@ +package shape + +import ( + "strings" + "testing" +) + +// TestRES10_DeriveSaysTheCdFoldWasCut: past MaxLeadingCd leading `cd DIR &&` +// steps the hook records the start directory as unknown (RES-10), so a path +// the line names relative to it is not known either, and the shape says the +// line was cut. MaxLeadingCd steps are folded and the line is not cut. +func TestRES10_DeriveSaysTheCdFoldWasCut(t *testing.T) { + line := func(n int) string { return strings.Repeat("cd a && ", n) + "touch f" } + if _, cut := DeriveCut("Bash", bashInput(t, line(MaxLeadingCd)), benchKey); cut { + t.Errorf("%d steps: cut, want the fold to read them all", MaxLeadingCd) + } + if _, cut := DeriveCut("Bash", bashInput(t, line(MaxLeadingCd+1)), benchKey); !cut { + t.Errorf("%d steps: not cut, want cut past the fold", MaxLeadingCd+1) + } +} diff --git a/internal/shape/res9_cut_test.go b/internal/shape/res9_cut_test.go new file mode 100644 index 00000000..0cb78402 --- /dev/null +++ b/internal/shape/res9_cut_test.go @@ -0,0 +1,82 @@ +package shape + +import ( + "encoding/json" + "slices" + "strings" + "testing" +) + +// TestRES9_DeriveSaysALineWasCut: a shell line read only to the token bound +// is reported as cut, so a writer that can carry the mark (schema 5's +// shape.truncated, P-11) never has to guess it. A line of exactly maxTokens +// tokens is read whole and is not cut; one token more is, and so is a +// here-document past the bound, whose program the bound keeps. A short line +// and a tool that is not a shell are not cut. +func TestRES9_DeriveSaysALineWasCut(t *testing.T) { + boundBuildable(t) + for _, tc := range []struct { + name, tool, cmd string + want bool + }{ + {"at the bound", "Bash", words(maxTokens - 1), false}, + {"one past the bound", "Bash", words(maxTokens), true}, + {"a here-document past the bound", "Bash", heredocLine(maxTokens), true}, + {"a short line", "Bash", "ls -la", false}, + {"not a shell", "Read", "", false}, + } { + in := bashInput(t, tc.cmd) + if tc.tool == "Read" { + in = json.RawMessage(`{"file_path":"/x"}`) + } + s, cut := DeriveCut(tc.tool, in, benchKey) + if cut != tc.want { + t.Errorf("%s: cut %v, want %v", tc.name, cut, tc.want) + } + if cut && !s.MayWrite { + t.Errorf("%s: a cut line with may_write false", tc.name) + } + if plain := Derive(tc.tool, in, benchKey); show(plain.Program) != show(s.Program) || plain.MayWrite != s.MayWrite || plain.Digest != s.Digest { + t.Errorf("%s: Derive and DeriveCut disagree on the shape", tc.name) + } + } +} + +// TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut: the ssh destinations +// are read over the token bound for the whole command (RES-9), so one named +// past it is not read, and before this the list came back short with nothing +// to say so. It is now reported as cut: past the bound within one line, and +// past it across lines, where each line costs at least one. The line count +// is tested at N and N+1. cut is about the ssh list: a URL host past the +// bound is still in the wire list, since the URL scan reads the whole text, +// while the same line is cut for the ssh reader, which stopped at the bound. +func TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut(t *testing.T) { + boundBuildable(t) + const h = "h.example.com" + for _, tc := range []struct { + name, cmd string + wantH bool + wantCut bool + }{ + {"named and read", "ssh " + h, true, false}, + {"past the bound in one line", words(maxTokens) + "; ssh " + h, false, true}, + {"the last line the budget reads", strings.Repeat("true\n", maxTokens-1) + "ssh " + h, true, false}, + {"the first line it does not", strings.Repeat("true\n", maxTokens) + "ssh " + h, false, true}, + {"a long last line cut at the bound", "ssh " + h + "\n" + words(maxTokens), true, true}, + } { + _, ssh, cut := HostsCut("Bash", bashInput(t, tc.cmd)) + if got := slices.Contains(ssh, h); got != tc.wantH { + t.Errorf("%s: %s in ssh_hosts %v, want %v", tc.name, h, got, tc.wantH) + } + if cut != tc.wantCut { + t.Errorf("%s: cut %v, want %v", tc.name, cut, tc.wantCut) + } + } + wire, _, cut := HostsCut("Bash", bashInput(t, words(maxTokens)+" https://w.example.com/x")) + if !slices.Contains(wire, "w.example.com") || !cut { + t.Errorf("a URL past the bound: wire %v, cut %v; want w.example.com, and cut for the ssh reader", wire, cut) + } + if _, _, cut := HostsCut("WebFetch", json.RawMessage(`{"url":"https://w.example.com/"}`)); cut { + t.Errorf("WebFetch: cut") + } +} diff --git a/internal/shape/shape.go b/internal/shape/shape.go index d504c0ed..15aa0ae5 100644 --- a/internal/shape/shape.go +++ b/internal/shape/shape.go @@ -108,6 +108,21 @@ type Shape struct { // different digests for identical input, so a store cannot be used as a lookup // table against a dictionary of known commands. func Derive(toolName string, toolInput json.RawMessage, key []byte) Shape { + // A record below schema 5 has no field for the cut, and the hook writes + // those: see DeriveCut, which is what a writer that can carry it calls. + s, _ := DeriveCut(toolName, toolInput, key) + return s +} + +// DeriveCut is Derive, and whether the shape was derived from a shell line +// read only in part: to the token bound (RES-9) or past the leading-cd fold +// (RES-10), where the hook records the start directory as unknown. What +// follows the cut is unknown rather than absent, so a path or a host the +// line names there is not in what was derived, and a reader must not take a +// short list for a complete one. It is schema 5's shape.truncated (Archive +// 85.3.10, "Token or cd bound"), which P-11 serialises; a v4 record does not +// carry it. Always false for a tool that is not a shell. +func DeriveCut(toolName string, toolInput json.RawMessage, key []byte) (Shape, bool) { s := Shape{VerbClass: verbForTool(toolName)} // Only a shell tool's input carries a command line. An MCP tool or a @@ -123,7 +138,7 @@ func Derive(toolName string, toolInput json.RawMessage, key []byte) Shape { } if !isShell { s.Digest = canonicalDigest(key, toolName, toolInput) - return s + return s, false } // A shell tool digests its command line and nothing else. Claude Code @@ -169,7 +184,8 @@ func Derive(toolName string, toolInput json.RawMessage, key []byte) Shape { // Where the line cannot be read, dirs is empty and no step is folded. dirs, _ := leadingRun(pshaped, perr) s.StatusMasked, s.RunnerDigest = statusMasked(cmd, key, leadingSteps(dirs)) - return s + cut := err == errTokenBound || perr == errTokenBound || len(dirs) > MaxLeadingCd + return s, cut } // controlByte reports a line holding a control byte, in which no word can be diff --git a/test/mutation/mutants/H-70_hosts.py b/test/mutation/mutants/H-70_hosts.py index 8d6c8a6e..09946148 100644 --- a/test/mutation/mutants/H-70_hosts.py +++ b/test/mutation/mutants/H-70_hosts.py @@ -31,7 +31,7 @@ "var valueFlags = map[string]string{\n\t\"ssh\": \"pPioljJFe\",\n\t\"scp\": \"pPioljJFe\",\n\t\"sftp\": \"pPioljJFe\",\n\t\"rsync\": \"pPioljJFe\",\n}", "TestH70_Forwarding|TestSSHFlagTables") m("H-70 ssh hosts come from URL schemes only", "internal/shape/hosts.go", - "\t\tssh = mergeHosts(ssh, sshCommandHosts(text))", "\t\t_ = sshCommandHosts", "TestH70_SSH|TestSSHDestinations") + "\t\tssh = mergeHosts(ssh, named)", "\t\t_ = named", "TestH70_SSH|TestSSHDestinations") m("H-70 every non-flag argument is recorded as a host", "internal/shape/hosts.go", "\t\tif prog == \"ssh\" || prog == \"sftp\" {\n\t\t\tif h, ok := destinationHost(a, false); ok {\n\t\t\t\tout = append(out, h)\n\t\t\t}\n\t\t\treturn out\n\t\t}\n\t\tif h, ok := destinationHost(a, true); ok {\n\t\t\tout = append(out, h)\n\t\t}", "\t\tif h, ok := destinationHost(a, false); ok {\n\t\t\tout = append(out, h)\n\t\t}", "TestH70_Local|TestSSHDestinations") diff --git a/test/mutation/mutants/RES-10_leading-cd.py b/test/mutation/mutants/RES-10_leading-cd.py index 20ac96e8..e83d46b2 100644 --- a/test/mutation/mutants/RES-10_leading-cd.py +++ b/test/mutation/mutants/RES-10_leading-cd.py @@ -13,3 +13,9 @@ m("RES-10 a line cut at the token bound gives up its leading cds", "internal/shape/leading.go", "\tif err != nil && err != errTokenBound {\n\t\treturn nil, false\n\t}\n", "\tif err != nil {\n\t\treturn nil, false\n\t}\n", "TestRES10_") + +# Past the fold the start directory is unknown, and the shape says the line +# was cut there; not saying so is the regression. +m("RES-10 a line past the leading-cd fold is not reported as cut", "internal/shape/shape.go", + "\tcut := err == errTokenBound || perr == errTokenBound || len(dirs) > MaxLeadingCd\n", + "\tcut := err == errTokenBound || perr == errTokenBound\n", "TestRES10_DeriveSaysTheCdFoldWasCut") diff --git a/test/mutation/mutants/RES-9_token-bound.py b/test/mutation/mutants/RES-9_token-bound.py index 5e62b8b4..159cfe5c 100644 --- a/test/mutation/mutants/RES-9_token-bound.py +++ b/test/mutation/mutants/RES-9_token-bound.py @@ -15,3 +15,22 @@ "TestRES9_TokenBoundCapsShapeMemory") m("RES-9 the token slice is grown, not sized", "internal/shape/tokenize.go", "\tl.toks = make([]token, 0, tokenEstimate(s))\n", "\tl.toks = []token(nil)\n", "TestRES9_TokenBoundCapsShapeMemory") + +# A line read only to the bound is reported as cut, by Derive and by the ssh +# destination reader, so a writer that can carry schema 5's shape.truncated +# never reads a short host or path list as a complete one. Not reporting a +# line Derive read to the bound, a line the ssh reader read to it, lines left +# past the ssh budget, or dropping the reader's cut in HostsCut is each the +# regression. +m("RES-9 Derive does not report a line read to the bound as cut", "internal/shape/shape.go", + "\tcut := err == errTokenBound || perr == errTokenBound || len(dirs) > MaxLeadingCd\n", + "\tcut := len(dirs) > MaxLeadingCd\n", "TestRES9_DeriveSaysALineWasCut") +m("RES-9 an ssh line read to the bound is not reported as cut", "internal/shape/hosts.go", + "\treturn out, len(toks), err == errTokenBound\n", "\treturn out, len(toks), err == errUncertain\n", + "TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut") +m("RES-9 lines left past the ssh budget are not reported as cut", "internal/shape/hosts.go", + "\t\tif lineCut || budget <= 0 && rest != \"\" {\n", "\t\tif lineCut {\n", + "TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut") +m("RES-9 HostsCut drops the ssh reader's cut", "internal/shape/hosts.go", + "\t\tnamed, cut = sshCommandHosts(text)\n", "\t\tnamed, _ = sshCommandHosts(text)\n", + "TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut") From 88dba8a309479b7f2e2a2539950b4df76c04f7a5 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Wed, 14 Oct 2026 03:52:07 -0700 Subject: [PATCH 22/50] res10/res29: gate on work, not wall time, under the race detector TestRES10_LeadingCdFoldIsBounded asserted a 50ms wall-clock ceiling on the leading-cd fold; under -race the fold's own cost is unchanged (measured identical TotalAlloc with and without the detector) but CPU time inflates 15-30x from instrumentation, so the assertion flaked. Replaced it with a TotalAlloc bound on the same two cases, logging elapsed time instead of gating on it. A new RES-10 mutant (MaxLeadingCd raised to 20000, still under the token-bound compile ceiling) proves the allocation bound is live: it measures 613 MB against the test's 12 MiB budget, where the existing 128-valued mutant only moves allocation by a few KB. TestRES29_PathologicalInputsStayBounded asserted an unconditional 4s hard stop on the hook subprocess. That deadline is not enforced by any process in this tree today -- 85.3.10's supervisor lands at P-23 and cmdHook has no timer of its own -- so a slow hook misses the same margin the test checks, not a mechanism failing to fire. Made the wall-clock check race-aware the same way the peak-RSS check beside it already is: skip and log under -race, enforce otherwise. Verified the existing RES-29 token-bound mutant, and all RES-10 mutants, stay RED under mutdrive after the change. --- cmd/rashomon/res29_test.go | 27 +++++++++++++++-- internal/hook/res10_test.go | 34 +++++++++++++++++----- test/mutation/mutants/RES-10_leading-cd.py | 18 ++++++++++++ 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/cmd/rashomon/res29_test.go b/cmd/rashomon/res29_test.go index a1de0b18..a0ad1dd9 100644 --- a/cmd/rashomon/res29_test.go +++ b/cmd/rashomon/res29_test.go @@ -62,7 +62,15 @@ func TestRES29_PathologicalInputsStayBounded(t *testing.T) { t.Skip("runs the hook in child processes on 8 MiB payloads") } const ( - deadline = 4 * time.Second // the hook's hard stop (85.3.10) + // deadline is the test's own ceiling on an uninstrumented build, not + // a hard stop this binary enforces: 85.3.10 budgets a supervisor + // that kills the child at this mark, but it is not built before + // P-23 (test/perf/gate.go:246), and cmdHook has no timer of its + // own. 4 s is the installed PreToolUse entry's 5 s Claude Code + // timeout minus the 1 s margin 85.3.10 gives every 5 s entry + // (plugin/hooks/hooks.json), so a hook this slow would miss that + // external timeout too, not just this test. + deadline = 4 * time.Second rssMiB = 64 ) type row struct { @@ -114,7 +122,22 @@ func TestRES29_PathologicalInputsStayBounded(t *testing.T) { if err != nil || out.Len() != 0 { t.Fatalf("hook: %v, output %q; want exit 0 and nothing printed", err, out.String()) } - if took > deadline { + // Wall time, like peak RSS below, is not a bound an instrumented + // child can be held to: the race detector's instrumentation + // inflates CPU time the same way its shadow memory inflates + // RSS. Measured 2026-10-14, darwin/arm64, go1.27.2, this + // machine under load: the backslash-newlines row took 290 ms + // plain and 4.25-7.49 s under -race across repeated runs (an + // observed 15-26x), while every row's non-race time stayed + // under 400 ms. That is detector overhead on work the token + // and masker bounds (RES-9) already cap, not this fold doing + // more of it, which the race-aware peak-RSS case two lines + // down establishes is the right way to read an instrumented + // child's numbers here. + switch { + case raceEnabled: + t.Logf("race detector on: hook took %v, the %v hard stop is not applied to an instrumented child", took, deadline) + case took > deadline: t.Errorf("hook took %v, past its %v hard stop", took, deadline) } switch { diff --git a/internal/hook/res10_test.go b/internal/hook/res10_test.go index 9f866c51..78975b77 100644 --- a/internal/hook/res10_test.go +++ b/internal/hook/res10_test.go @@ -2,6 +2,7 @@ package hook import ( "encoding/json" + "runtime" "strings" "testing" "time" @@ -32,8 +33,19 @@ func fastest(f func()) time.Duration { // re-cleaned the whole growing path, so the fold was quadratic: a 480 KB // payload took 10.9 s against the hook's 5 s timeout. A 1 MB `cd a&&` // payload, and the longest fold the shape's token bound lets through, each -// finish inside 50 ms, and with more than 64 steps each start in a directory -// that is not known. +// allocate a few MiB and no more, and with more than 64 steps each start in +// a directory that is not known. +// +// The bound below is TotalAlloc, not wall time: this project's gates are +// allocated bytes and bounded work, never wall clock (RES-9's own rule). +// Wall time is logged only. Measured 2026-10-14, darwin/arm64, go1.27.2: the +// 1 MB case allocates 5,810,288 B whether or not the race detector is +// instrumenting the binary (it also took 6.6 ms plain and 93.0 ms under +// -race -- an unrelated CPU-time inflation from the detector's +// instrumentation, not from this fold doing more work, which a wall-clock +// gate here could not tell apart from a real regression). A quadratic +// re-break would show up as allocation growing with the payload, which this +// catches regardless of how fast or slow the machine running it is. // // A line that runs on past the shape's token bound (RES-9) still has its // leading steps read: they come first, and 65 of them are 195 tokens, far @@ -57,17 +69,23 @@ func TestRES10_LeadingCdFoldIsBounded(t *testing.T) { t.Errorf("65 steps: start directory %q, want unknown", got) } for _, tc := range []struct { - name string - cmd string + name string + cmd string + bound uint64 // TotalAlloc bytes over the 3 runs fastest makes; measured well under this }{ - {"1 MB of cd a&&", strings.Repeat("cd a&&", (1<<20)/len("cd a&&"))}, - {"14,000 steps, under the token bound", cdLine(14_000, "&&")}, + {"1 MB of cd a&&", strings.Repeat("cd a&&", (1<<20)/len("cd a&&")), 36 << 20}, + {"14,000 steps, under the token bound", cdLine(14_000, "&&"), 12 << 20}, } { raw := in(tc.cmd) var got string + var m0, m1 runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&m0) took := fastest(func() { got = startDirectory("/repo", "Bash", raw) }) - if took > 50*time.Millisecond { - t.Errorf("%s: the fold took %v, want at most 50ms", tc.name, took) + runtime.ReadMemStats(&m1) + t.Logf("%s: the fold took %v", tc.name, took) + if alloc := m1.TotalAlloc - m0.TotalAlloc; alloc > tc.bound { + t.Errorf("%s: the fold allocated %d KiB over 3 runs, want at most %d KiB: a quadratic re-break grows allocation, not just wall time", tc.name, alloc>>10, tc.bound>>10) } if got != "" { t.Errorf("%s: start directory %q, want unknown", tc.name, got) diff --git a/test/mutation/mutants/RES-10_leading-cd.py b/test/mutation/mutants/RES-10_leading-cd.py index e83d46b2..e6045510 100644 --- a/test/mutation/mutants/RES-10_leading-cd.py +++ b/test/mutation/mutants/RES-10_leading-cd.py @@ -19,3 +19,21 @@ m("RES-10 a line past the leading-cd fold is not reported as cut", "internal/shape/shape.go", "\tcut := err == errTokenBound || perr == errTokenBound || len(dirs) > MaxLeadingCd\n", "\tcut := err == errTokenBound || perr == errTokenBound\n", "TestRES10_DeriveSaysTheCdFoldWasCut") + +# RES-10's cmd/rashomon sibling (RES-29) pins peak RSS on the hook end to +# end; it cannot attribute that RSS to the fold alone. This pins the fold's +# own allocation, in package hook, which is what lets TestRES10_LeadingCdFoldIsBounded +# gate on TotalAlloc instead of wall time (85.3.10 is not a clock this test +# can hold an uninstrumented build to, let alone a -race one). 20000 still +# compiles (maxTokens is 62601 today; 3*(20000+1) = 60003 clears it, where +# 3*(129) does not clear it at the old spec's 41,700 estimate -- this is why +# the sibling mutant above stops at 128), and it is far past the 14,000 cds +# TestRES10_LeadingCdFoldIsBounded's "14,000 steps, under the token bound" +# case holds under the token bound: past the fold, that line's leading cds +# all collect instead of being cut off at 64, and handle.go's startDirectory +# then joins and re-cleans a growing path once per step (the quadratic +# re-break this file's own header comment names) -- measured 2026-10-14, +# 613 MB over 3 runs against the test's 12 MiB-per-run bound, not the few +# KB over 64 steps the 128-valued mutant above produces. +m("RES-10 the fold's own allocation is uncapped well under the token bound", "internal/shape/leading.go", + "const MaxLeadingCd = 64\n", "const MaxLeadingCd = 20000\n", "TestRES10_LeadingCdFoldIsBounded") From 8e4b2cd96fecca12b5f0b3f08bbe9f65d91ee63e Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Wed, 14 Oct 2026 10:08:57 -0700 Subject: [PATCH 23/50] schema: the hosts, ssh_hosts, cwd_digest and host_digest bounds --- docs/store-schema.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/store-schema.json b/docs/store-schema.json index 7596c9bb..8bb41353 100644 --- a/docs/store-schema.json +++ b/docs/store-schema.json @@ -175,7 +175,7 @@ } }, "hosts": { - "description": "Schema 2. Hostnames this call NAMED, behind http(s):// or ws(s)://, canonicalised: lower-cased, port stripped, one trailing dot dropped, userinfo refused outright rather than stripped (CWE-522). A declaration and not an observation -- the host is here because the agent wrote it down, whether or not the call ran and whether or not the wire ever saw it, which is what makes the report's \"reached but never named\" line meaningful. Sorted and de-duplicated, so two runs of one command produce byte-identical records. Null when the call named none, never an empty array: \"we looked and found none\" must stay distinguishable from a schema 1 record that was never looked at.", + "description": "Schema 2. Hostnames this call NAMED, behind http(s):// or ws(s)://, canonicalised: lower-cased, port stripped, one trailing dot dropped, userinfo refused outright rather than stripped (CWE-522), and an IP literal written in its one spelling (IPv6 compressed and bracketed, an IPv4-mapped address as its IPv4 address, every inet_aton form of an IPv4 address as a dotted quad). Only a host-shaped value is recorded: a bracketed IPv6 address or a name of at most 253 bytes of letters, digits, -, _ and ., so text a URL's authority swallowed (api.example.com=...!x) is not. A Unicode name is kept as written, not converted to its xn-- form. A declaration and not an observation -- the host is here because the agent wrote it down, whether or not the call ran and whether or not the wire ever saw it, which is what makes the report's \"reached but never named\" line meaningful. Sorted and de-duplicated, so two runs of one command produce byte-identical records. Null when the call named none, never an empty array: \"we looked and found none\" must stay distinguishable from a schema 1 record that was never looked at.", "type": [ "array", "null" @@ -185,7 +185,7 @@ } }, "ssh_hosts": { - "description": "Schema 2. Hostnames named behind ssh://, git+ssh:// or git@, canonicalised identically. Kept apart from hosts because family coverage counts ssh and HTTPS destinations separately, by scheme; novelty does not make that distinction and takes its candidates from this field and hosts combined.", + "description": "Schema 2. Hostnames named behind ssh://, git+ssh:// or git@, or as the destination argument of ssh, scp, sftp or rsync on the command line, canonicalised identically. The command line is read for those arguments line by line up to the shape token bound (62,601 tokens, each line counting at least one), so a destination named past it is not recorded; nothing marks such a list incomplete until schema 5. Kept apart from hosts because family coverage counts ssh and HTTPS destinations separately, by scheme; novelty does not make that distinction and takes its candidates from this field and hosts combined.", "type": [ "array", "null" @@ -195,7 +195,7 @@ } }, "cwd_digest": { - "description": "Schema 3. HMAC under the per-install key of the directory the call's command started in: the PreToolUse payload's cwd, which follows the shell from one call to the next, or where the line's leading plain `cd DIR &&` steps lead (no expansion, quoting, ~ or -; a leading pushd, popd or other cd keeps the payload's cwd): an absolute target replaces the directory and a relative one joins it. Two runs of one command line compare as the same run only when this is equal as well, since the same line in another directory runs other code. A digest and never the path: the declarations do not carry the directory, which the coverage records already keep as the project key. Empty string when that directory is not known: no payload cwd and no absolute leading cd.", + "description": "Schema 3. HMAC under the per-install key of the directory the call's command started in: the PreToolUse payload's cwd, which follows the shell from one call to the next, or where the line's leading plain `cd DIR &&` steps lead (no expansion, quoting, ~ or -; a leading pushd, popd or other cd keeps the payload's cwd): an absolute target replaces the directory and a relative one joins it. Two runs of one command line compare as the same run only when this is equal as well, since the same line in another directory runs other code. A digest and never the path: the declarations do not carry the directory, which the coverage records already keep as the project key. Empty string when that directory is not known: no payload cwd and no absolute leading cd, or more than 64 leading cd steps, past which the fold stops.", "type": "string" }, "file_label": { @@ -543,7 +543,7 @@ ] }, "reason": { - "description": "Null when the outcome is ok. recovered_panic: the panic barrier recovered a panic that cost this call's capture -- a bug in this program, as distinct from internal_error's ordinary failure.", + "description": "Null when the outcome is ok. recovered_panic: the panic barrier recovered a panic that cost this call's capture -- a bug in this program, as distinct from internal_error's ordinary failure. flush_failed: carried by a spill terminal, it marks records this call appended and could not flush to stable storage -- they read back, and a power loss or a crash of the machine could still remove them.", "type": [ "string", "null" @@ -629,7 +629,7 @@ ] }, "reason": { - "description": "Null when the state is verified, and never null when it is unverified: a reader meets an unverified record with no reason, or a state outside the enum, as records_unreadable. Fixed codes, never free text and never derived from input. recovered_panic: the panic barrier recovered a panic in this invocation -- one that cost the call's capture, or one inside the file-label derivation, whose label then reads unknown.", + "description": "Null when the state is verified, and never null when it is unverified: a reader meets an unverified record with no reason, or a state outside the enum, as records_unreadable. Fixed codes, never free text and never derived from input. recovered_panic: the panic barrier recovered a panic in this invocation -- one that cost the call's capture, or one inside the file-label derivation, whose label then reads unknown. payload_too_large: the invocation's payload was over the 8 MiB intake cap and was refused after a bounded read, so the call went unrecorded; nothing broke inside this program, which is why it is not internal_error.", "type": [ "string", "null" @@ -755,7 +755,7 @@ "type": "integer" }, "host_digest": { - "description": "Schema 2. An HMAC under the per-install key identifying the destination a host-scoped forget removed; empty string for a window forget, which removes by time and names no host. A DIGEST and not the hostname, resolving a conflict the field had to settle: the report needs to keep suppressing the host, because nono's own trail is not this tool's to delete from and the host would otherwise reappear in the next report under the sandbox heading, yet forget --host also has to make the hostname leave this store's bytes. Writing the name would satisfy the first requirement by breaking the second. Keyed with the same per-install key the shape digests use, so the report can recompute it from an observed host while a reader of the store cannot turn it back into a name or test a guess.", + "description": "Schema 2. An HMAC under the per-install key identifying the destination a host-scoped forget removed; empty string for a window forget, which removes by time and names no host. A DIGEST and not the hostname, resolving a conflict the field had to settle: the report needs to keep suppressing the host, because nono's own trail is not this tool's to delete from and the host would otherwise reappear in the next report under the sandbox heading, yet forget --host also has to make the hostname leave this store's bytes. Writing the name would satisfy the first requirement by breaking the second. Keyed with the same per-install key the shape digests use, so the report can recompute it from an observed host while a reader of the store cannot turn it back into a name or test a guess. Computed over the host's one canonical spelling (lower case, no port or root dot, an IP literal as it is recorded), whatever the user typed: forget --host 167838211 removes the records naming 10.1.2.3 and digests 10.1.2.3, the spelling the sandbox trail carries. A gap written before that digests the value as typed.", "type": "string" }, "removed_records": { From e45b19e3ce8ba5537cc8b7f227440b14ac8dc004 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Wed, 14 Oct 2026 20:30:48 -0700 Subject: [PATCH 24/50] shape: cap the runner groups statusMasked re-runs the line for statusMasked's abstract run re-reads the whole parsed line once per distinctly-worded runner group, so a line of many groups costs their product rather than their sum. A line of thousands of them -- each `make` target named once -- made that the square of the payload: 16,000 plain runners took 4.5s, and 4,000 piped to a later stage allocated 4.4 GB. Past maxRunnerGroups the kind is null, the same answer a line this function already gives up reading returns for a dozen other reasons. --- internal/shape/masked.go | 17 ++++++ internal/shape/masked_runner_cap_test.go | 53 +++++++++++++++++++ .../mutants/RES-29_runner-group-cap.py | 8 +++ 3 files changed, 78 insertions(+) create mode 100644 internal/shape/masked_runner_cap_test.go create mode 100644 test/mutation/mutants/RES-29_runner-group-cap.py diff --git a/internal/shape/masked.go b/internal/shape/masked.go index 9b50e1e3..afeffdb5 100644 --- a/internal/shape/masked.go +++ b/internal/shape/masked.go @@ -52,6 +52,12 @@ var buildCommands = [][]string{ {"dotnet", "build"}, {"cmake", "--build"}, {"ninja"}, {"bazel", "build"}, {"bazel", "test"}, } +// maxRunnerGroups bounds how many distinctly-worded runners statusMasked +// will re-run the line for. See the check inside statusMasked for why: past +// it, the kind is null, the same answer a line this function gives up +// reading already returns. +const maxRunnerGroups = 64 + // statusMasked reports, for a shell line, whether a recognised build or test // runner on it has an exit status the line does not return, and the keyed // digest of that runner's own words -- or, when none does, of a runner whose @@ -109,6 +115,17 @@ func statusMasked(cmd string, key []byte, held int) (kind, runner *string) { if !ok { return nil, nil } + if len(p.runners) > maxRunnerGroups { + // Not sure: null. runShells below re-runs the whole parsed line for + // every group (twice, or four times where the line turns errexit + // on), so the cost is runners times line size -- a line of + // thousands of distinctly-named runners, each its own group because + // runnerWords keys a group by its exact words, made that the + // square of the payload: 16,000 `make tN;` groups took 4.5s and + // 4,000 `make tN|tail;` ones allocated 4.4 GB. A line this shaped is + // already past what a build or test script looks like. + return nil, nil + } p.displace(held) var ( diff --git a/internal/shape/masked_runner_cap_test.go b/internal/shape/masked_runner_cap_test.go new file mode 100644 index 00000000..4811865f --- /dev/null +++ b/internal/shape/masked_runner_cap_test.go @@ -0,0 +1,53 @@ +package shape + +import ( + "strconv" + "strings" + "testing" +) + +// TestStatusMaskedRunnerGroupsAreCapped: statusMasked's abstract run re-reads +// the whole parsed line once per distinctly-worded runner group (runShells), +// so a line of many groups is the product of group count and line size. A +// line of 2,000 runners each piped to a later stage -- `make t0|tail;make +// t1|tail;...` -- measured 1,104 MiB and 574 ms before maxRunnerGroups +// existed; capped, the same line allocates a few MiB. The gate is +// allocation, not wall time, as the rest of this package's RES-29 tests are. +func TestStatusMaskedRunnerGroupsAreCapped(t *testing.T) { + line := func(n int) string { + var b strings.Builder + for i := 0; i < n; i++ { + b.WriteString("make t") + b.WriteString(strconv.Itoa(i)) + b.WriteString("|tail;") + } + return b.String() + } + const bound = 64 << 20 // measured ~5.6 MiB capped; 2,000 groups uncapped measured 1,104 MiB + in := bashInput(t, line(2000)) + if alloc := allocated(func() { Derive("Bash", in, benchKey) }); alloc > bound { + t.Errorf("2,000 distinctly-worded runners allocated %d MiB, want at most %d: past maxRunnerGroups this is the square of the line, not its size", alloc>>20, bound>>20) + } + s := Derive("Bash", in, benchKey) + if s.StatusMasked != nil { + t.Errorf("status_masked %v past maxRunnerGroups, want null", show(s.StatusMasked)) + } + // At and one past maxRunnerGroups' own value (64, written here as a + // literal so a mutant that changes the constant does not also change + // how many groups this case builds): a plain runner's status reaches + // the line, so joined entirely by && it is MaskedNone up to the cap, + // and null one past it. + andLine := func(n int) string { + parts := make([]string, n) + for i := range parts { + parts[i] = "make t" + strconv.Itoa(i) + } + return strings.Join(parts, " && ") + } + if s := Derive("Bash", bashInput(t, andLine(64)), benchKey); s.StatusMasked == nil || *s.StatusMasked != MaskedNone { + t.Errorf("64 distinctly-worded runners joined by &&: status_masked %v, want %q", show(s.StatusMasked), MaskedNone) + } + if s := Derive("Bash", bashInput(t, andLine(65)), benchKey); s.StatusMasked != nil { + t.Errorf("65 distinctly-worded runners: status_masked %v, want null", show(s.StatusMasked)) + } +} diff --git a/test/mutation/mutants/RES-29_runner-group-cap.py b/test/mutation/mutants/RES-29_runner-group-cap.py new file mode 100644 index 00000000..eca96f14 --- /dev/null +++ b/test/mutation/mutants/RES-29_runner-group-cap.py @@ -0,0 +1,8 @@ +# RES-29: statusMasked re-runs the whole parsed line once per distinctly- +# worded runner group, so a line of thousands of them is quadratic. Lifting +# the cap, or the comparison letting one group past it, each let a line this +# shaped back into the loop that reads the line once per group. +m("RES-29 the runner-group cap is lifted", "internal/shape/masked.go", + "const maxRunnerGroups = 64\n", "const maxRunnerGroups = 1 << 30\n", "TestStatusMaskedRunnerGroupsAreCapped") +m("RES-29 the runner-group cap is off by one", "internal/shape/masked.go", + "\tif len(p.runners) > maxRunnerGroups {\n", "\tif len(p.runners) > maxRunnerGroups+1 {\n", "TestStatusMaskedRunnerGroupsAreCapped") From cad02e10b61e26347d9f3db5e311c183969cc941 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Wed, 14 Oct 2026 20:30:59 -0700 Subject: [PATCH 25/50] shape: bound how far authority scans for its terminator authority's terminator set does not include '@' -- the whole authority has to reach host.Canonical for its userinfo refusal to see it -- or, past an unclosed '[', ']'. A command line of nothing but one scheme repeated gave it no terminator before the line's own end, and collect calls it once per match, so a line of N matches scanned the whole remaining line N times: 100 KB of `git@` took over a second here (2 minutes measured elsewhere), and an unclosed `https://[` the same way. Past authorityWindow bytes -- host.maxName plus room for a port -- no terminator has been found by the point a real host:port would already have ended, so the candidate is refused rather than truncated: truncating would hand Canonical a prefix that may have lost the '@' or ']' the wider scan existed to find. Both shapes are added to the pathological line table so the hook-level RES-29 test measures them end to end alongside the fix. --- internal/shape/hosts.go | 41 +++++++++++++++---- internal/shape/hosts_authority_window_test.go | 39 ++++++++++++++++++ .../mutants/RES-29_authority-window.py | 9 ++++ test/perf/pathological/pathological.go | 2 + 4 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 internal/shape/hosts_authority_window_test.go create mode 100644 test/mutation/mutants/RES-29_authority-window.py diff --git a/internal/shape/hosts.go b/internal/shape/hosts.go index 3ccd0095..838476ef 100644 --- a/internal/shape/hosts.go +++ b/internal/shape/hosts.go @@ -477,27 +477,52 @@ func collect(lower string, prefixes []string) []string { // authority instead lets Canonical see the '@' and refuse the input outright // (CWE-522). Port stripping is Canonical's job, and it does it after that // check, which is the only order in which both answers are right. +// +// The scan is bounded to authorityWindow bytes, not the rest of the command +// line. Neither '@' nor, inside a bracket, ']' is in the terminator set +// above, so a line of nothing but one scheme repeated -- "git@git@git@...", +// "https://[https://[..." -- gave this function no terminator before the +// line's own end, and collect calls it once per match: a line of N matches +// scanned the whole remaining line N times. Past the window, no terminator +// has been found by the point a real host:port would already have ended, so +// the candidate is refused (""), not truncated at the window -- truncating +// would hand Canonical a prefix that may have lost the '@' or ']' the scan +// was widened to find, which is the property the comment above describes. func authority(s string) string { - if strings.HasPrefix(s, "[") { - if end := strings.Index(s, "]"); end >= 0 { + limit, cut := s, false + if len(limit) > authorityWindow { + limit, cut = limit[:authorityWindow], true + } + if strings.HasPrefix(limit, "[") { + if end := strings.Index(limit, "]"); end >= 0 { // Keep any :port after the bracket; Canonical strips it. - return s[:end+1] + portSuffix(s[end+1:]) + return limit[:end+1] + portSuffix(limit[end+1:]) } return "" } - for i := 0; i < len(s); i++ { - c := s[i] + for i := 0; i < len(limit); i++ { + c := limit[i] if c <= ' ' || c == 0x7f { - return s[:i] + return limit[:i] } switch c { case '/', '?', '#', '\\', '"', '\'', '`', '$', '|', '&', ';', '(', ')', '{', '}', '<', '>', ',', '*': - return s[:i] + return limit[:i] } } - return s + if cut { + return "" + } + return limit } +// authorityWindow is how far authority looks for its terminator: host.maxName +// (253, unexported there) plus 8 bytes of margin for a ":" and a port, which +// is more room than the 5 digits a real port needs. A host:port this product +// will ever accept ends inside it; TestRubricLLD2_URLHostsAreHostnameShaped's +// 253- and 254-byte cases pin the boundary against host.Canonical directly. +const authorityWindow = 253 + 8 + // portSuffix returns a leading ":digits" run, and nothing else. It exists so a // bracketed IPv6 authority keeps its port for Canonical to strip, without // swallowing the path that follows. diff --git a/internal/shape/hosts_authority_window_test.go b/internal/shape/hosts_authority_window_test.go new file mode 100644 index 00000000..0cd7ed51 --- /dev/null +++ b/internal/shape/hosts_authority_window_test.go @@ -0,0 +1,39 @@ +package shape + +import ( + "strings" + "testing" + "time" +) + +// TestAuthorityWindowBoundsTheScan: authority's terminator set does not +// include '@' (the whole authority has to reach host.Canonical for the +// CWE-522 refusal to see it) or, past an unclosed '[', ']', so a command +// line of nothing but one scheme repeated gave authority no terminator +// before the line's own end: collect calls it once per match, so a line of +// N matches scanned the whole remaining line N times. +// +// The gate here is wall time, not allocation: the extra work past the +// window is a scan over bytes already read, which costs no more heap than +// the bounded scan does (measured: both allocate about 104 KB on the git@ +// case). Measured 2026-10-14, darwin/arm64, go1.27.2: bounded, 200 KB of +// git@ and 1.8 MB of an unclosed https://[ take 18.7 ms and 23.2 ms plain, +// 30.5 ms and 165 ms under -race; unbounded (authorityWindow raised past +// both lines), 4.45 s and 2.44 s. The bound below has over 5x the margin +// race adds and well under a tenth of either unbounded time. +func TestAuthorityWindowBoundsTheScan(t *testing.T) { + const bound = time.Second + for _, tc := range []struct{ name, line string }{ + {"git@ repeated", strings.Repeat("git@", 50_000)}, // 200 KB + {"https://[ repeated, never closed", strings.Repeat("https://[", 200_000)}, // 1.8 MB + } { + t.Run(tc.name, func(t *testing.T) { + in := bashInput(t, tc.line) + start := time.Now() + Hosts("Bash", in) + if took := time.Since(start); took > bound { + t.Errorf("%s: Hosts took %v, want at most %v: unbounded, authority scans to the line's end at every match", tc.name, took, bound) + } + }) + } +} diff --git a/test/mutation/mutants/RES-29_authority-window.py b/test/mutation/mutants/RES-29_authority-window.py new file mode 100644 index 00000000..aece4923 --- /dev/null +++ b/test/mutation/mutants/RES-29_authority-window.py @@ -0,0 +1,9 @@ +# RES-29: authority's scan for its terminator is bounded to authorityWindow +# bytes, past host.maxName plus room for a port. Lifting the window, or +# returning a truncated candidate instead of refusing one past it, each put +# the unbounded scan -- to the end of a command line at every scheme match -- +# back. +m("RES-29 the authority scan window is lifted", "internal/shape/hosts.go", + "const authorityWindow = 253 + 8\n", "const authorityWindow = 1 << 30\n", "TestAuthorityWindowBoundsTheScan") +m("RES-29 a cut authority is truncated instead of refused", "internal/shape/hosts.go", + "\tif cut {\n\t\treturn \"\"\n\t}\n\treturn limit\n", "\treturn limit\n", "TestAuthorityWindowBoundsTheScan") diff --git a/test/perf/pathological/pathological.go b/test/perf/pathological/pathological.go index f8e9a5c2..da9f44ec 100644 --- a/test/perf/pathological/pathological.go +++ b/test/perf/pathological/pathological.go @@ -36,5 +36,7 @@ func Lines() []Line { {"short lines", "echo", "\na", ""}, {"ssh lines", "", "ssh a\n", ""}, {"urls", "curl", " https://a.b/", ""}, + {"git@ scheme runs", "", "git@", ""}, + {"unterminated bracket hosts", "", "https://[", ""}, } } From ec87654af184d2c9f883652aebc6a05cfb565268 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Wed, 14 Oct 2026 20:31:06 -0700 Subject: [PATCH 26/50] shape: gate two tests on allocation, or wall time only under no race TestRubricLLD6_JoinsAreSearchedNotScanned held a 2s wall clock unconditionally, and the nesting test beside RES-29 held 50ms the same way. The join search costs no more heap than the scan it replaced -- both walk a slice already in memory -- so TotalAlloc cannot stand in for it there; the wall-clock bound now applies only outside the race detector's own CPU inflation, logged instead of enforced under it, the way cmd/rashomon's RES-29 already holds its hook deadline. The nesting test's own cost is allocation-correlated, so it moves to that gate directly. --- internal/shape/race_off_test.go | 11 +++++++++++ internal/shape/race_on_test.go | 6 ++++++ internal/shape/res29_test.go | 29 ++++++++++++++--------------- internal/shape/rubric_lld6_test.go | 16 ++++++++++++++-- 4 files changed, 45 insertions(+), 17 deletions(-) create mode 100644 internal/shape/race_off_test.go create mode 100644 internal/shape/race_on_test.go diff --git a/internal/shape/race_off_test.go b/internal/shape/race_off_test.go new file mode 100644 index 00000000..bf6d59c5 --- /dev/null +++ b/internal/shape/race_off_test.go @@ -0,0 +1,11 @@ +//go:build !race + +package shape + +// raceEnabled says whether this binary is instrumented by the race +// detector, which inflates CPU time (and so wall time) well past what the +// same work costs plain -- a cost this package's allocation-gated tests +// never see, because TotalAlloc is unchanged either way. A wall-clock bound +// can only be held to an uninstrumented build; under -race it is logged, +// not enforced. +const raceEnabled = false diff --git a/internal/shape/race_on_test.go b/internal/shape/race_on_test.go new file mode 100644 index 00000000..a58db2b6 --- /dev/null +++ b/internal/shape/race_on_test.go @@ -0,0 +1,6 @@ +//go:build race + +package shape + +// raceEnabled: see race_off_test.go. +const raceEnabled = true diff --git a/internal/shape/res29_test.go b/internal/shape/res29_test.go index 9b8b7bed..688a757a 100644 --- a/internal/shape/res29_test.go +++ b/internal/shape/res29_test.go @@ -18,6 +18,10 @@ import ( // A "$(echo " pair costs two levels, the substitution and the quoted string // inside it, so the flip is between 16 and 17 pairs; a bare $( ) or a $(( )) // inside another costs one, so it is between 33 and 34. +// +// The gate below is TotalAlloc, not wall time, as this package's gates +// always are (RES-9's own rule): wall time is logged only. Measured +// 2026-10-14, darwin/arm64, go1.27.2: each case allocates about 11 KiB. func TestRES29_NestingAtAndPastMaxNest(t *testing.T) { quoted := func(n int) string { return `cd "` + strings.Repeat(`$(echo "`, n) + "x" + strings.Repeat(`")`, n) + `" && ls` @@ -43,10 +47,17 @@ func TestRES29_NestingAtAndPastMaxNest(t *testing.T) { } for _, n := range []int{tc.deepest, tc.deepest + 1} { in := bashInput(t, tc.line(n)) + const bound = 1 << 20 var s Shape - took := fastest(func() { s = Derive("Bash", in, benchKey) }) - if took > 50*time.Millisecond { - t.Errorf("%s, %d levels: %v, want at most 50ms", tc.name, n, took) + var took time.Duration + alloc := allocated(func() { + start := time.Now() + s = Derive("Bash", in, benchKey) + took = time.Since(start) + }) + t.Logf("%s, %d levels: %v, allocated %d KiB", tc.name, n, took, alloc>>10) + if alloc > bound { + t.Errorf("%s, %d levels: allocated %d KiB, want at most %d", tc.name, n, alloc>>10, bound>>10) } accepted := n == tc.deepest switch { @@ -58,15 +69,3 @@ func TestRES29_NestingAtAndPastMaxNest(t *testing.T) { } } } - -// fastest is the least time any of three runs of f took, so a busy machine's -// pauses stay out of a deadline comparison. -func fastest(f func()) time.Duration { - best := time.Duration(1 << 62) - for i := 0; i < 3; i++ { - start := time.Now() - f() - best = min(best, time.Since(start)) - } - return best -} diff --git a/internal/shape/rubric_lld6_test.go b/internal/shape/rubric_lld6_test.go index 2ae1ceef..f7a328f3 100644 --- a/internal/shape/rubric_lld6_test.go +++ b/internal/shape/rubric_lld6_test.go @@ -16,6 +16,14 @@ import ( // the body, and a continuation inside the body still gives up the reading. // The line holds fewer continuations than maxJoins, so every offset is kept // and the body is read. +// +// The scan this guards against costs no more heap than the search it +// replaced -- both walk a slice already in memory -- so TotalAlloc cannot +// tell them apart; the gate is wall time, held only in a build the race +// detector is not instrumenting, the same way cmd/rashomon's RES-29 holds +// its hook deadline. Measured 2026-10-14, darwin/arm64, go1.27.2: the line +// below takes 26-29 ms plain. The bound is a fraction of the 13.8 s the +// doc comment above measures for the regression at a larger size. func TestRubricLLD6_JoinsAreSearchedNotScanned(t *testing.T) { joins, lines := 40_000, 400_000 if joins >= maxJoins { @@ -23,11 +31,15 @@ func TestRubricLLD6_JoinsAreSearchedNotScanned(t *testing.T) { } line := "echo " + strings.Repeat("a\\\n", joins) + "; cat <&1 | tail" in := bashInput(t, line) + const bound = time.Second var s Shape start := time.Now() s = Derive("Bash", in, benchKey) - if took := time.Since(start); took > 2*time.Second { - t.Errorf("a 920 KB line of continuations and a here-document took %v, want at most 2s", took) + switch took := time.Since(start); { + case raceEnabled: + t.Logf("race detector on: a 920 KB line of continuations and a here-document took %v, the %v bound is not applied to an instrumented build", took, bound) + case took > bound: + t.Errorf("a 920 KB line of continuations and a here-document took %v, want at most %v: scanning every kept offset per body line would make this the product of the two, not their sum", took, bound) } if s.StatusMasked == nil || *s.StatusMasked != MaskedTest { t.Errorf("status_masked %v, want %q: the runner after the body is read", show(s.StatusMasked), MaskedTest) From c403e59c678f0aa7183cf719e23780d91541704d Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Wed, 14 Oct 2026 20:31:14 -0700 Subject: [PATCH 27/50] baseline, report: canonicalise legacy hosts wherever they are read back A file written before host.Canonical folded an IP literal to its one spelling holds the uncanonicalised string -- "167838211" where a session now names "10.1.2.3" -- and compared as stored, a host the baseline already knew read as novel again. load now re-keys every host through host.Canonical and host.Shaped, merging what collapses together (earliest FirstSeenSession, latest LastSeenAtUnixMS) and dropping what is refused. declaredHosts has the same problem one level up: a run recorded before host.Canonical existed holds the raw string in its own declaration, and re-rendering that old run fed it to the baseline walk unchanged, so the same host read as a second, unknown one on every later render. It now canonicalises what it reads out of a declaration the same way. --- internal/baseline/baseline.go | 1 + internal/baseline/canonical_test.go | 114 ++++++++++++++++++ internal/baseline/fold.go | 48 ++++++++ internal/report/destinations.go | 28 +++-- .../report/destinations_legacy_host_test.go | 66 ++++++++++ .../RUBRIC_lld-11_baseline-canonical.py | 17 +++ .../RUBRIC_lld-11_declared-hosts-canonical.py | 7 ++ 7 files changed, 268 insertions(+), 13 deletions(-) create mode 100644 internal/baseline/canonical_test.go create mode 100644 internal/report/destinations_legacy_host_test.go create mode 100644 test/mutation/mutants/RUBRIC_lld-11_baseline-canonical.py create mode 100644 test/mutation/mutants/RUBRIC_lld-11_declared-hosts-canonical.py diff --git a/internal/baseline/baseline.go b/internal/baseline/baseline.go index dce756cc..ee4dc9c9 100644 --- a/internal/baseline/baseline.go +++ b/internal/baseline/baseline.go @@ -283,6 +283,7 @@ func load(path, key string) (File, bool, error) { if f.Hosts == nil { f.Hosts = map[string]entry{} } + f.Hosts = canonicalizeHosts(f.Hosts) return f, true, nil } diff --git a/internal/baseline/canonical_test.go b/internal/baseline/canonical_test.go new file mode 100644 index 00000000..351c5edf --- /dev/null +++ b/internal/baseline/canonical_test.go @@ -0,0 +1,114 @@ +package baseline + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + "time" +) + +// writeLegacyBaseline writes a baseline file as a pre-upgrade build would +// have: a host key in whatever form that build's host.Canonical (if it had +// one at all) produced, never re-keyed for an IP literal's one spelling. +func writeLegacyBaseline(t *testing.T, root, key string, hosts map[string]entry) string { + t.Helper() + dir := filepath.Join(root, "baseline") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + f := File{Version: Version, Project: key, Basis: Basis, Hosts: hosts} + body, err := json.Marshal(f) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, fileName(key)) + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// TestUpdate_LegacyIPLiteralIsNotNovelAgain: a baseline written before +// host.Canonical folded an IP literal to its one spelling holds the +// uncanonicalised string. A session naming the same host in its canonical +// form must not read as novel, and the two keys must merge into one host, +// not two. +func TestUpdate_LegacyIPLiteralIsNotNovelAgain(t *testing.T) { + root := t.TempDir() + writeLegacyBaseline(t, root, "/proj", map[string]entry{ + "167838211": {FirstSeenSession: "sess-0", FirstSeenAtUnixMS: t0.UnixMilli()}, + }) + + res := Update(root, "/proj", "sess-1", t0.Add(time.Hour), []string{"10.1.2.3"}) + + if len(res.Novel) != 0 { + t.Errorf("novel = %v, want empty: 167838211 and 10.1.2.3 are one host", res.Novel) + } + if res.Known != 1 { + t.Errorf("known = %d, want 1: the legacy key and the canonical one must merge", res.Known) + } +} + +// TestLoad_MergesCollidingLegacyKeys: two pre-upgrade keys that canonicalise +// to the same host merge into one entry, keeping the earlier +// FirstSeenSession and the later LastSeenAtUnixMS, as Fold's own ownership +// rule decides both. +// +// "10.1.2.3" sorts before "167838211", so the merge processes it first and +// has to correct away from it: the later session landed in the map first, +// and the earlier one -- read second -- has to displace it, which is the +// direction a map walked in insertion order would never need to. +func TestLoad_MergesCollidingLegacyKeys(t *testing.T) { + root := t.TempDir() + path := writeLegacyBaseline(t, root, "/proj", map[string]entry{ + // "10.1.2.3" sorts first and carries the later FirstSeenAtUnixMS and + // the earlier LastSeenAtUnixMS, so a correct merge has to take the + // FirstSeenSession from the *other* key and the LastSeenAtUnixMS + // from it too -- the one direction a merge that only ever kept the + // first-processed entry's fields would get both halves wrong in. + "10.1.2.3": {FirstSeenSession: "sess-late", FirstSeenAtUnixMS: t0.Add(2 * time.Hour).UnixMilli(), LastSeenAtUnixMS: t0.Add(2 * time.Hour).UnixMilli()}, + "167838211": {FirstSeenSession: "sess-early", FirstSeenAtUnixMS: t0.UnixMilli(), LastSeenAtUnixMS: t0.Add(3 * time.Hour).UnixMilli()}, + }) + + f, existed, err := load(path, "/proj") + if err != nil { + t.Fatal(err) + } + if !existed { + t.Fatal("existed = false for a readable basis-matching file") + } + if len(f.Hosts) != 1 { + t.Fatalf("hosts = %v, want one merged entry", f.Hosts) + } + e, ok := f.Hosts["10.1.2.3"] + if !ok { + t.Fatalf("hosts = %v, want the 10.1.2.3 key", f.Hosts) + } + if e.FirstSeenSession != "sess-early" { + t.Errorf("first_seen_session = %q, want sess-early: the earlier session owns it", e.FirstSeenSession) + } + if want := t0.Add(3 * time.Hour).UnixMilli(); e.LastSeenAtUnixMS != want { + t.Errorf("last_seen_at_unix_ms = %d, want %d, the later of the two", e.LastSeenAtUnixMS, want) + } +} + +// TestLoad_RefusesAnUnshapedLegacyKey: a key host.Canonical maps to +// something host.Shaped refuses -- or that it refuses outright -- is +// dropped rather than carried forward under a new spelling. +func TestLoad_RefusesAnUnshapedLegacyKey(t *testing.T) { + root := t.TempDir() + path := writeLegacyBaseline(t, root, "/proj", map[string]entry{ + "api.example.com=ghp_x": {FirstSeenSession: "sess-0", FirstSeenAtUnixMS: t0.UnixMilli()}, + "good.example": {FirstSeenSession: "sess-0", FirstSeenAtUnixMS: t0.UnixMilli()}, + }) + + f, _, err := load(path, "/proj") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(f.Hosts, map[string]entry{"good.example": f.Hosts["good.example"]}) { + t.Errorf("hosts = %v, want only good.example", f.Hosts) + } +} diff --git a/internal/baseline/fold.go b/internal/baseline/fold.go index 65db9837..55dcfe3f 100644 --- a/internal/baseline/fold.go +++ b/internal/baseline/fold.go @@ -6,6 +6,8 @@ import ( "maps" "sort" "time" + + "github.com/altrace-dev-role/rashomon/internal/host" ) // lockName is the lock file beside the baselines, taken around every @@ -177,6 +179,52 @@ func lastSeen(e entry) int64 { return e.FirstSeenAtUnixMS } +// canonicalizeHosts re-keys a loaded baseline through host.Canonical and +// host.Shaped, merging what collapses together and dropping what is +// refused. +// +// A file written before host.Canonical folded an IP literal to its one +// spelling holds the uncanonicalised string -- "167838211" where a session +// now names "10.1.2.3" -- and compared as stored, a host the baseline +// already knew read as novel again. A key host.Canonical cannot read, or +// that host.Shaped refuses once canonicalised, is dropped: it was never a +// host, only something authority() stopped scanning at before this +// package's own shape check existed downstream of it. +// +// Collision keeps the earliest FirstSeenSession and the later of the two +// LastSeenAtUnixMS (lastSeen), the same ownership rule Fold applies when two +// sessions name one host. Keys are walked in sorted order so that a tie -- +// both entries predate the LastSeenAtUnixMS field, or name the same instant +// -- resolves the same way on every render rather than on map iteration +// order. +func canonicalizeHosts(hosts map[string]entry) map[string]entry { + keys := make([]string, 0, len(hosts)) + for h := range hosts { + keys = append(keys, h) + } + sort.Strings(keys) + out := make(map[string]entry, len(hosts)) + for _, h := range keys { + e := hosts[h] + canon, ok := host.Canonical(h) + if !ok || !host.Shaped(canon) { + continue + } + prev, seen := out[canon] + if !seen { + out[canon] = e + continue + } + next := prev + if e.FirstSeenAtUnixMS > 0 && (prev.FirstSeenAtUnixMS == 0 || e.FirstSeenAtUnixMS < prev.FirstSeenAtUnixMS) { + next.FirstSeenSession, next.FirstSeenAtUnixMS = e.FirstSeenSession, e.FirstSeenAtUnixMS + } + next.LastSeenAtUnixMS = max(lastSeen(prev), lastSeen(e)) + out[canon] = next + } + return out +} + // readBounded reads a baseline file, refusing one over maxBaselineBytes. func readBounded(r io.Reader) ([]byte, error) { body, err := io.ReadAll(io.LimitReader(r, maxBaselineBytes+1)) diff --git a/internal/report/destinations.go b/internal/report/destinations.go index 2b1cbb5a..98295bec 100644 --- a/internal/report/destinations.go +++ b/internal/report/destinations.go @@ -386,18 +386,7 @@ func sshDeclaredFiltered(run *store.Run, forgotten func(host string, declaredAtM // what has been forgotten, to decide which trail hosts are the client's own. // See declaredHostsFiltered for the view that applies forgotten. func declaredHosts(run *store.Run) []string { - set := map[string]bool{} - for _, d := range run.Declarations { - for _, h := range d.Hosts { - set[h] = true - } - } - out := make([]string, 0, len(set)) - for h := range set { - out = append(out, h) - } - sort.Strings(out) - return out + return declaredHostsFiltered(run, nil) } // declaredHostsFiltered is declaredHosts with forgotten applied per @@ -410,7 +399,20 @@ func declaredHostsFiltered(run *store.Run, forgotten func(host string, declaredA if forgotten != nil && forgotten(h, d.RecordedAtMS) { continue } - set[h] = true + // A run stored before host.Canonical folded an IP literal to + // its one spelling holds the raw string -- "167838211" where a + // current build now writes "10.1.2.3". Re-canonicalising here + // is a no-op for every run shape.Hosts already wrote the + // canonical form into, and is what lets an old run compare + // equal to a new one in the baseline novelty walks this feeds + // (buildNovelty). A host that no longer canonicalises, or that + // host.Shaped now refuses, is dropped rather than carried + // forward under whatever it used to be. + canon, ok := host.Canonical(h) + if !ok || !host.Shaped(canon) { + continue + } + set[canon] = true } } out := make([]string, 0, len(set)) diff --git a/internal/report/destinations_legacy_host_test.go b/internal/report/destinations_legacy_host_test.go new file mode 100644 index 00000000..ac4aa245 --- /dev/null +++ b/internal/report/destinations_legacy_host_test.go @@ -0,0 +1,66 @@ +package report + +import ( + "testing" + + "github.com/altrace-dev-role/rashomon/internal/store" +) + +// TestNovelty_LegacyIPLiteralDeclarationIsNotNovelAgain: a run recorded +// before host.Canonical folded an IP literal to its one spelling holds the +// uncanonicalised string. Read today, under the one spelling a current +// build now writes for the same host, it must not read as a new +// destination. +func TestNovelty_LegacyIPLiteralDeclarationIsNotNovelAgain(t *testing.T) { + project := t.TempDir() + root := t.TempDir() + mk := func(id string, at int64, hosts ...string) *store.Run { + return &store.Run{ + Declarations: []store.Declaration{{ + ToolUseID: "t", ToolName: "Bash", SessionID: id, Hosts: hosts, + }}, + Coverage: []store.Coverage{{ + Phase: store.PhaseStart, RecordedAtMS: at, CWD: project, SessionID: id, + }}, + } + } + // A pre-upgrade session recorded the host in its raw, uncanonicalised + // form. + buildDestinations(mk("sess-1", t0ms, "167838211"), root, nil) + + // A current session names the same host in its canonical spelling. + d := buildDestinations(mk("sess-2", t0ms+3600000, "10.1.2.3"), root, nil) + + if len(d.Novelty.Hosts) != 0 { + t.Errorf("novelty hosts = %v, want empty: 167838211 and 10.1.2.3 are one host", d.Novelty.Hosts) + } +} + +// TestNovelty_RerenderingAPreUpgradeRunIsNotNovel: the reverse order from +// above -- a CURRENT session establishes the baseline under a host's +// canonical spelling, and an OLDER run, stored before host.Canonical +// existed, is rendered afterwards holding the host's raw, uncanonicalised +// form. declaredHosts has to canonicalise what it reads out of that old +// record, or the same host reads as a second, unknown one every time that +// old run is rendered again. +func TestNovelty_RerenderingAPreUpgradeRunIsNotNovel(t *testing.T) { + project := t.TempDir() + root := t.TempDir() + mk := func(id string, at int64, hosts ...string) *store.Run { + return &store.Run{ + Declarations: []store.Declaration{{ + ToolUseID: "t", ToolName: "Bash", SessionID: id, Hosts: hosts, + }}, + Coverage: []store.Coverage{{ + Phase: store.PhaseStart, RecordedAtMS: at, CWD: project, SessionID: id, + }}, + } + } + buildDestinations(mk("sess-new", t0ms, "10.1.2.3"), root, nil) + + d := buildDestinations(mk("sess-old", t0ms+3600000, "167838211"), root, nil) + + if len(d.Novelty.Hosts) != 0 { + t.Errorf("novelty hosts = %v, want empty: 167838211 is the same host as 10.1.2.3", d.Novelty.Hosts) + } +} diff --git a/test/mutation/mutants/RUBRIC_lld-11_baseline-canonical.py b/test/mutation/mutants/RUBRIC_lld-11_baseline-canonical.py new file mode 100644 index 00000000..3e9cf6fa --- /dev/null +++ b/test/mutation/mutants/RUBRIC_lld-11_baseline-canonical.py @@ -0,0 +1,17 @@ +# A baseline written before host.Canonical folded an IP literal to its one +# spelling holds the uncanonicalised string, and compared as stored, a host +# the baseline already knew reads as novel again. Skipping the re-key on +# load, accepting an unshaped key, or losing either half of the ownership +# merge is the regression. +m("RUBRIC lld-11 load does not re-key legacy hosts", "internal/baseline/baseline.go", + "\tf.Hosts = canonicalizeHosts(f.Hosts)\n\treturn f, true, nil\n", + "\treturn f, true, nil\n", "TestUpdate_LegacyIPLiteralIsNotNovelAgain|TestLoad_") +m("RUBRIC lld-11 an unshaped legacy key is kept", "internal/baseline/fold.go", + "\t\tcanon, ok := host.Canonical(h)\n\t\tif !ok || !host.Shaped(canon) {\n\t\t\tcontinue\n\t\t}\n", + "\t\tcanon, ok := host.Canonical(h)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n", "TestLoad_RefusesAnUnshapedLegacyKey") +m("RUBRIC lld-11 a colliding legacy key loses the later LastSeenAtUnixMS", "internal/baseline/fold.go", + "\t\tnext.LastSeenAtUnixMS = max(lastSeen(prev), lastSeen(e))\n", + "\t\tnext.LastSeenAtUnixMS = lastSeen(prev)\n", "TestLoad_MergesCollidingLegacyKeys") +m("RUBRIC lld-11 a colliding legacy key loses the earlier FirstSeenSession", "internal/baseline/fold.go", + "\t\tif e.FirstSeenAtUnixMS > 0 && (prev.FirstSeenAtUnixMS == 0 || e.FirstSeenAtUnixMS < prev.FirstSeenAtUnixMS) {\n", + "\t\tif false {\n", "TestLoad_MergesCollidingLegacyKeys") diff --git a/test/mutation/mutants/RUBRIC_lld-11_declared-hosts-canonical.py b/test/mutation/mutants/RUBRIC_lld-11_declared-hosts-canonical.py new file mode 100644 index 00000000..0caaf866 --- /dev/null +++ b/test/mutation/mutants/RUBRIC_lld-11_declared-hosts-canonical.py @@ -0,0 +1,7 @@ +# declaredHosts feeds the baseline novelty walk (buildNovelty). A run stored +# before host.Canonical folded an IP literal to its one spelling holds the +# raw string, and reading it back without canonicalising made that host read +# as a second, unknown one on every later render of the same old run. +m("RUBRIC lld-11 declaredHosts does not canonicalise its keys", "internal/report/destinations.go", + "\t\t\tcanon, ok := host.Canonical(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[canon] = true\n", + "\t\t\tset[h] = true\n", "TestNovelty_LegacyIPLiteralDeclarationIsNotNovelAgain|TestNovelty_RerenderingAPreUpgradeRunIsNotNovel") From c5971c536c1cb2ad8d26c54510828eaed5e870fe Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Wed, 14 Oct 2026 20:31:30 -0700 Subject: [PATCH 28/50] shape: gofmt the authority window test --- internal/shape/hosts_authority_window_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/shape/hosts_authority_window_test.go b/internal/shape/hosts_authority_window_test.go index 0cd7ed51..9645392a 100644 --- a/internal/shape/hosts_authority_window_test.go +++ b/internal/shape/hosts_authority_window_test.go @@ -24,7 +24,7 @@ import ( func TestAuthorityWindowBoundsTheScan(t *testing.T) { const bound = time.Second for _, tc := range []struct{ name, line string }{ - {"git@ repeated", strings.Repeat("git@", 50_000)}, // 200 KB + {"git@ repeated", strings.Repeat("git@", 50_000)}, // 200 KB {"https://[ repeated, never closed", strings.Repeat("https://[", 200_000)}, // 1.8 MB } { t.Run(tc.name, func(t *testing.T) { From 87b2dbbc6f6c01f74d3316b5f81236cc03ee8510 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Sat, 17 Oct 2026 11:30:13 -0700 Subject: [PATCH 29/50] report: canonicalise a host before asking whether it was forgotten forget --host records its digest under the CANONICAL spelling of whatever was typed, never the one a declaration happens to hold. declaredHostsFiltered and sshDeclaredFiltered canonicalised a host for the output set but asked the forgotten() check about the stored spelling first, so a declaration from before host.Canonical existed -- "167838211" where a current build writes "10.1.2.3" -- did not match the digest and escaped the check, reappearing in destinations.declared_hosts on every later render of the run that still held it, even after forgetting 10.1.2.3 reported success. sshDeclaredFiltered had the same hole one level wider: it never canonicalised an ssh host at all, on the way into the forgotten check or into the output set, so a mixed-case or otherwise legacy-spelled ssh host both escaped a forget of its canonical name and never got folded to the one spelling every current declaration uses. Both functions now canonicalise first and ask forgotten about that spelling, the same order ForgetHost itself already used when it was fixed to ask about a host in the spelling it is recorded in. Re-anchors the lld-11 declaredHosts mutant for the inserted forgotten check and adds lld-11b for the new one, proven red against a throwaway copy. --- internal/report/destinations.go | 31 ++++- internal/report/seam_test.go | 118 ++++++++++++++++++ .../RUBRIC_lld-11_declared-hosts-canonical.py | 16 ++- ...UBRIC_lld-11b_forgotten-check-canonical.py | 29 +++++ 4 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 test/mutation/mutants/RUBRIC_lld-11b_forgotten-check-canonical.py diff --git a/internal/report/destinations.go b/internal/report/destinations.go index 98295bec..ec9cdbd2 100644 --- a/internal/report/destinations.go +++ b/internal/report/destinations.go @@ -361,14 +361,28 @@ func sshDeclared(run *store.Run) []string { // never for a later declaration that happens to share it. forgotten == nil // passes every host through, matching the report's nil-means-no-filter // convention (see suppressTrail). +// +// Canonicalised before forgotten is asked, the same correction +// declaredHostsFiltered applies and for the same reason: forget --host +// records its digest under the CANONICAL spelling, never the one a +// declaration happens to hold, so asking forgotten about the stored spelling +// let a legacy or differently-cased ssh host -- "GitHub.com" where today's +// extraction writes "github.com" -- survive a forget of its canonical name +// and kept it out of the one spelling every current declaration uses. A host +// that no longer canonicalises, or that host.Shaped now refuses, is dropped +// rather than carried forward under whatever it used to be. func sshDeclaredFiltered(run *store.Run, forgotten func(host string, declaredAtMS int64) bool) []string { set := map[string]bool{} for _, d := range run.Declarations { for _, h := range d.SSHHosts { - if forgotten != nil && forgotten(h, d.RecordedAtMS) { + canon, ok := host.Canonical(h) + if !ok || !host.Shaped(canon) { continue } - set[h] = true + if forgotten != nil && forgotten(canon, d.RecordedAtMS) { + continue + } + set[canon] = true } } out := make([]string, 0, len(set)) @@ -396,9 +410,6 @@ func declaredHostsFiltered(run *store.Run, forgotten func(host string, declaredA set := map[string]bool{} for _, d := range run.Declarations { for _, h := range d.Hosts { - if forgotten != nil && forgotten(h, d.RecordedAtMS) { - continue - } // A run stored before host.Canonical folded an IP literal to // its one spelling holds the raw string -- "167838211" where a // current build now writes "10.1.2.3". Re-canonicalising here @@ -408,10 +419,20 @@ func declaredHostsFiltered(run *store.Run, forgotten func(host string, declaredA // (buildNovelty). A host that no longer canonicalises, or that // host.Shaped now refuses, is dropped rather than carried // forward under whatever it used to be. + // + // Canonicalised BEFORE forgotten is asked, not after: forget + // --host records its digest under the canonical spelling, never + // the stored one, so asking forgotten about h here let a legacy + // declaration -- 167838211 where a forget named 10.1.2.3 -- + // escape the check and reappear in every later render of the + // same old run. canon, ok := host.Canonical(h) if !ok || !host.Shaped(canon) { continue } + if forgotten != nil && forgotten(canon, d.RecordedAtMS) { + continue + } set[canon] = true } } diff --git a/internal/report/seam_test.go b/internal/report/seam_test.go index 19be4b67..544f03de 100644 --- a/internal/report/seam_test.go +++ b/internal/report/seam_test.go @@ -701,6 +701,124 @@ func TestSeam_APartlyFailedForgetStillSuppressesInDestinationsFamiliesAndChains( } } +// legacySpellingOutlivesAForget is the fixture both tests below share: a +// declaration recorded in a spelling host.Canonical would not write today, +// forgotten by its canonical name, with the owning run's own records file +// made unopenable first so forgetHostInRun's physical removal cannot touch +// it -- the same #45 trick TestSeam_APartlyFailedForgetStillSuppressesIn... +// uses, here to isolate the forgotten() PREDICATE as the only thing left +// that could hide the host, rather than the per-run removal that would +// otherwise also canonicalise-and-match it via host.Same and mask this bug. +// toolUseID and hosts/sshHosts are mutually exclusive: exactly one of hosts +// or sshHosts is non-empty, naming the field the fixture populates. +func legacySpellingOutlivesAForget(t *testing.T, id, toolUseID string, hosts, sshHosts []string, canonical string) *Report { + t.Helper() + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("a file mode does not stop this process here") + } + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + now := time.UnixMilli(1_700_000_000_000) + if err := st.AppendDeclaration(store.Declaration{ + Type: "declaration", SchemaVersion: 4, SessionID: id, + ToolUseID: toolUseID, ToolName: "Bash", + RecordedAtMS: now.UnixMilli(), Hosts: hosts, SSHHosts: sshHosts, + }); err != nil { + t.Fatal(err) + } + + runs, err := st.Runs() + if err != nil || len(runs) != 1 { + t.Fatalf("premise: one run, got %v (%v)", runs, err) + } + records := filepath.Join(st.Root(), "runs", runs[0], "records.ndjson") + if err := os.Chmod(records, 0); err != nil { + t.Fatal(err) + } + _, forgetErr := st.ForgetHost(canonical, now) + // Restored before anything reads the run again: the mode exists only to + // fail forgetHostInRun's own open, not every later read, including the + // report build below. + if err := os.Chmod(records, 0o600); err != nil { + t.Fatal(err) + } + if forgetErr == nil { + t.Fatal("premise: a forget whose run cannot be opened reported success") + } + forgotten, err := st.ForgottenHost() + if err != nil { + t.Fatal(err) + } + if !forgotten(canonical) { + t.Fatal("premise: the store-level forget record was not written, so nothing " + + "downstream has anything to suppress") + } + + // PREMISE: the run's own declaration still names the host in its stored + // spelling -- forgetHostInRun never got to remove it. Without this the + // test would pass because the record was actually gone, which is the + // case the per-run removal path already covers on its own. + raw, err := st.ReadRun(id) + if err != nil { + t.Fatal(err) + } + names := append(append([]string{}, hosts...), sshHosts...) + var stillDeclared bool + for _, d := range raw.Declarations { + for _, name := range names { + if slices.Contains(d.Hosts, name) || slices.Contains(d.SSHHosts, name) { + stillDeclared = true + } + } + } + if !stillDeclared { + t.Fatal("premise: the failed forget actually removed the declaration, so this " + + "test exercises nothing beyond the per-run removal path") + } + + rep, err := Build(st, id, now) + if err != nil { + t.Fatal(err) + } + return rep +} + +// TestSeam_ForgetFindsADeclarationStoredInALegacySpelling: a declaration +// written before host.Canonical folded an IP literal to its one spelling +// holds the raw string -- "167838211" where a current build would have +// written "10.1.2.3" for the same curl. declaredHostsFiltered checked +// forgotten() against that stored spelling before canonicalising it for the +// output set, so a `forget --host 10.1.2.3` -- which records its digest +// under the CANONICAL name, never the stored one -- could not recognise the +// legacy declaration as the host it was asked to forget, and it kept +// reappearing under destinations.declared_hosts on every later render. +func TestSeam_ForgetFindsADeclarationStoredInALegacySpelling(t *testing.T) { + rep := legacySpellingOutlivesAForget(t, "seam-legacy-host", "toolu_legacy", + []string{"167838211"}, nil, "10.1.2.3") + if got := rep.Sessions[0].Destinations.DeclaredHosts; slices.Contains(got, "167838211") || slices.Contains(got, "10.1.2.3") { + t.Errorf("destinations.declared_hosts still carries a legacy-spelling declaration "+ + "after forgetting its canonical name: %v", got) + } +} + +// TestSeam_ForgetFindsAnSSHDeclarationStoredInALegacySpelling: the same hole, +// for sshDeclaredFiltered -- which, unlike declaredHostsFiltered, did not +// canonicalise an ssh host at all before comparing it to forgotten() or +// before adding it to the output set. A declaration recorded in mixed case, +// from before this build's ssh extraction ran every host through +// host.Canonical, never matched the canonical digest `forget --host` +// records. +func TestSeam_ForgetFindsAnSSHDeclarationStoredInALegacySpelling(t *testing.T) { + rep := legacySpellingOutlivesAForget(t, "seam-legacy-ssh-host", "toolu_legacy_ssh", + nil, []string{"GitHub.com"}, "github.com") + if got := rep.Sessions[0].Destinations.SSHHosts; slices.Contains(got, "GitHub.com") || slices.Contains(got, "github.com") { + t.Errorf("destinations.ssh_hosts still carries a legacy-spelling ssh declaration "+ + "after forgetting its canonical name: %v", got) + } +} + // nonoJSON is the sandbox section of the first session as its JSON reader // sees it. func nonoJSON(t *testing.T, rep *Report) map[string]any { diff --git a/test/mutation/mutants/RUBRIC_lld-11_declared-hosts-canonical.py b/test/mutation/mutants/RUBRIC_lld-11_declared-hosts-canonical.py index 0caaf866..7c6e0095 100644 --- a/test/mutation/mutants/RUBRIC_lld-11_declared-hosts-canonical.py +++ b/test/mutation/mutants/RUBRIC_lld-11_declared-hosts-canonical.py @@ -2,6 +2,18 @@ # before host.Canonical folded an IP literal to its one spelling holds the # raw string, and reading it back without canonicalising made that host read # as a second, unknown one on every later render of the same old run. +# +# Re-anchored when the forgotten-check reorder (RUBRIC lld-11b) landed: the +# canonicalise-and-shape block this mutant removes now has the forgotten +# check inlined after it, so the anchor covers through set[canon] = true. m("RUBRIC lld-11 declaredHosts does not canonicalise its keys", "internal/report/destinations.go", - "\t\t\tcanon, ok := host.Canonical(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[canon] = true\n", - "\t\t\tset[h] = true\n", "TestNovelty_LegacyIPLiteralDeclarationIsNotNovelAgain|TestNovelty_RerenderingAPreUpgradeRunIsNotNovel") + "// declaration -- 167838211 where a forget named 10.1.2.3 --\n" + "\t\t\t// escape the check and reappear in every later render of the\n" + "\t\t\t// same old run.\n" + "\t\t\tcanon, ok := host.Canonical(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n" + "\t\t\tif forgotten != nil && forgotten(canon, d.RecordedAtMS) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[canon] = true\n", + "// declaration -- 167838211 where a forget named 10.1.2.3 --\n" + "\t\t\t// escape the check and reappear in every later render of the\n" + "\t\t\t// same old run.\n" + "\t\t\tif forgotten != nil && forgotten(h, d.RecordedAtMS) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[h] = true\n", + "TestNovelty_LegacyIPLiteralDeclarationIsNotNovelAgain|TestNovelty_RerenderingAPreUpgradeRunIsNotNovel") diff --git a/test/mutation/mutants/RUBRIC_lld-11b_forgotten-check-canonical.py b/test/mutation/mutants/RUBRIC_lld-11b_forgotten-check-canonical.py new file mode 100644 index 00000000..b5680016 --- /dev/null +++ b/test/mutation/mutants/RUBRIC_lld-11b_forgotten-check-canonical.py @@ -0,0 +1,29 @@ +# forget --host records its digest under the CANONICAL spelling, never the +# one a declaration happens to hold. declaredHostsFiltered and +# sshDeclaredFiltered canonicalised a host for the output set but asked +# forgotten() about the stored spelling, so a declaration recorded before +# host.Canonical existed -- or an ssh host sshDeclaredFiltered never +# canonicalised at all -- did not match the digest and escaped the check, +# reappearing in destinations.declared_hosts / destinations.ssh_hosts on +# every later render of the run that still held it, even after a forget of +# its canonical name reported success. +m("RUBRIC lld-11b declaredHostsFiltered asks forgotten about the stored spelling", "internal/report/destinations.go", + "// declaration -- 167838211 where a forget named 10.1.2.3 --\n" + "\t\t\t// escape the check and reappear in every later render of the\n" + "\t\t\t// same old run.\n" + "\t\t\tcanon, ok := host.Canonical(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n" + "\t\t\tif forgotten != nil && forgotten(canon, d.RecordedAtMS) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[canon] = true\n", + "// declaration -- 167838211 where a forget named 10.1.2.3 --\n" + "\t\t\t// escape the check and reappear in every later render of the\n" + "\t\t\t// same old run.\n" + "\t\t\tcanon, ok := host.Canonical(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n" + "\t\t\tif forgotten != nil && forgotten(h, d.RecordedAtMS) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[canon] = true\n", + "TestSeam_ForgetFindsADeclarationStoredInALegacySpelling") +m("RUBRIC lld-11b sshDeclaredFiltered asks forgotten about the stored spelling", "internal/report/destinations.go", + "for _, h := range d.SSHHosts {\n" + "\t\t\tcanon, ok := host.Canonical(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n" + "\t\t\tif forgotten != nil && forgotten(canon, d.RecordedAtMS) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[canon] = true\n", + "for _, h := range d.SSHHosts {\n" + "\t\t\tcanon, ok := host.Canonical(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n" + "\t\t\tif forgotten != nil && forgotten(h, d.RecordedAtMS) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[canon] = true\n", + "TestSeam_ForgetFindsAnSSHDeclarationStoredInALegacySpelling") From 2bd0262155ece8a7f48b644b07bee172743938c8 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Sun, 18 Oct 2026 09:58:28 -0700 Subject: [PATCH 30/50] host, shape, baseline, report, store: ssh destinations are not URLs, stop folding them as one Canonical folds a bare number into an IPv4 address (inet_aton's historic decimal/octal/hex/short-dotted forms), which is right for a URL authority a client is actually going to resolve. It is wrong for an ssh/scp/sftp/rsync destination: those can be Host aliases in ~/.ssh/config, and `ssh 1234` names the alias "1234", not 0.0.4.210. destinationHost was calling the same Canonical for both, so an ssh alias that happened to parse as inet_aton input got silently rewritten to an address it never named, and the baseline, the report's ssh_hosts list, and forget --host all disagreed with what the command line actually said. Split a private canonical(raw, foldNumeric) out of Canonical, and added CanonicalName as the non-folding half for ssh destinations specifically (Loopback/LoopbackName the same way). destinationHost now calls CanonicalName. The harder part is that canonicalizeHosts re-folds the baseline's whole host map on every load, and existing tests pin that behavior for legacy wire hosts recorded before Canonical existed (TestUpdate_LegacyIPLiteralIsNotNovelAgain, TestLoad_MergesCollidingLegacyKeys). Since the baseline has no per-host origin tag, wire and ssh hosts were indistinguishable once stored, so I added one: entry.SSH, defaulting false for every existing record, and threaded it through new UpdateOrigin/FoldOrigin functions that wrap (not replace) Update/Fold so none of the ~40 existing baseline tests needed to change. canonicalizeHosts picks CanonicalName over Canonical per entry based on that flag. Changing sshDeclaredFiltered to read the ssh list back unfolded would have broken forget --host for ssh aliases, since ForgetHost only recorded a digest under the folded spelling. ForgetHost now also records a second digest under the unfolded spelling when it differs from the folded one, so forgetting an ssh alias by its own name still matches what's stored. novelHostCandidates and buildNovelty take wire and ssh hosts separately for the same reason, and ask Loopback differently: host.Loopback folds before asking (right for a URL), host.LoopbackName doesn't (right for an alias like "0", which is not a destination that names this machine). Novelty.Evicted surfaces baseline.Result.Evicted so a render says when a host aged out of the 4,096-entry cap instead of going silently novel again. sshCommandHosts, which destinationHost's caller walks line by line, had its own separate bound bug: sshLineHosts's lexer doesn't know about the token budget shared across the whole multi-line command, so it always reads a line up to the full maxTokens regardless of how much of the shared budget is left. A line reached with almost none of the budget remaining could still cost a full line's worth of tokenizing, roughly doubling the stated bound in total. Capped each line's bytes at the budget actually left (bytesPerToken already prices a token in bytes for RES-9) before handing it to the lexer. --- internal/baseline/baseline.go | 24 +++++- internal/baseline/fold.go | 30 ++++++- internal/baseline/ssh_origin_test.go | 82 +++++++++++++++++++ internal/host/host.go | 61 ++++++++++++-- internal/host/host_test.go | 63 ++++++++++++++ internal/report/destinations.go | 64 +++++++++++---- internal/report/destinations_test.go | 3 +- internal/report/rubric_lld4_test.go | 4 +- internal/report/text_destinations.go | 7 ++ internal/report/tru57_sections_test.go | 9 +- internal/shape/hosts.go | 21 ++++- internal/shape/hosts_test.go | 37 +++++++++ internal/store/gaps.go | 15 ++++ test/mutation/mutants/NONO_nono.py | 9 +- test/mutation/mutants/NOV_ssh-candidates.py | 4 +- test/mutation/mutants/REC-32_reader-limits.py | 2 + .../RUBRIC_lld-11_baseline-canonical.py | 4 +- ...UBRIC_lld-11b_forgotten-check-canonical.py | 4 +- .../mutants/RUBRIC_lld-4_spellings.py | 6 +- 19 files changed, 403 insertions(+), 46 deletions(-) create mode 100644 internal/baseline/ssh_origin_test.go diff --git a/internal/baseline/baseline.go b/internal/baseline/baseline.go index ee4dc9c9..0f3056be 100644 --- a/internal/baseline/baseline.go +++ b/internal/baseline/baseline.go @@ -79,6 +79,15 @@ type entry struct { FirstSeenSession string `json:"first_seen_session"` FirstSeenAtUnixMS int64 `json:"first_seen_at_unix_ms"` LastSeenAtUnixMS int64 `json:"last_seen_at_unix_ms,omitempty"` + // SSH marks a host named only through an ssh/scp/sftp/rsync destination, + // never through a URL a client resolves. canonicalizeHosts reads it to + // skip the inet_aton fold for such a host: an ssh destination may be a + // config alias nothing here ever resolves (host.CanonicalName's doc), and + // folding a numeric one to the address inet_aton would read it as records + // a connection under a name nobody typed. A legacy entry with no field at + // all reads as false, which keeps the fold for a file old enough to + // predate the distinction -- the migration this package's own tests pin. + SSH bool `json:"ssh,omitempty"` } // File is one project's baseline. @@ -224,12 +233,21 @@ func fileName(key string) string { // render could each undo the other. A render that learns nothing writes // nothing. func Update(root, key, sessionID string, sessionStart time.Time, hosts []string) Result { + return UpdateOrigin(root, key, sessionID, sessionStart, hosts, nil) +} + +// UpdateOrigin is Update, telling it which of hosts were named only through +// an ssh-family destination rather than a URL a client resolves (entry.SSH): +// sshHosts[h] true marks h so a later load does not fold it as an IP +// literal. A nil map, which is what Update passes, marks nothing -- every +// host folds exactly as it did before this parameter existed. +func UpdateOrigin(root, key, sessionID string, sessionStart time.Time, hosts []string, sshHosts map[string]bool) Result { if root == "" || key == "" { return Result{Err: errors.New("baseline: no store root or project key")} } dir := filepath.Join(root, "baseline") path := filepath.Join(dir, fileName(key)) - res, err := update(dir, path, key, sessionID, sessionStart, hosts) + res, err := update(dir, path, key, sessionID, sessionStart, hosts, sshHosts) res.Path = path if err != nil { res.Err = err @@ -238,7 +256,7 @@ func Update(root, key, sessionID string, sessionStart time.Time, hosts []string) } // update is Update's locked load-fold-save. -func update(dir, path, key, sessionID string, sessionStart time.Time, hosts []string) (Result, error) { +func update(dir, path, key, sessionID string, sessionStart time.Time, hosts []string, sshHosts map[string]bool) (Result, error) { if err := os.MkdirAll(dir, 0o700); err != nil { return Result{}, err } @@ -251,7 +269,7 @@ func update(dir, path, key, sessionID string, sessionStart time.Time, hosts []st if err != nil { return Result{}, err } - next, res, changed := Fold(f, existed, sessionID, sessionStart, hosts) + next, res, changed := FoldOrigin(f, existed, sessionID, sessionStart, hosts, sshHosts) if res.Err != nil || !changed { return res, nil } diff --git a/internal/baseline/fold.go b/internal/baseline/fold.go index 55dcfe3f..8abb5ffb 100644 --- a/internal/baseline/fold.go +++ b/internal/baseline/fold.go @@ -59,6 +59,16 @@ var errTooLarge = errors.New("baseline: file larger than a baseline can be") // in. Each host also records the latest session that named it, which decides // eviction at the cap. func Fold(f File, existed bool, sessionID string, sessionStart time.Time, hosts []string) (File, Result, bool) { + return foldOrigin(f, existed, sessionID, sessionStart, hosts, nil) +} + +// FoldOrigin is Fold, telling it which of hosts were named only through an +// ssh-family destination; see UpdateOrigin and entry.SSH. +func FoldOrigin(f File, existed bool, sessionID string, sessionStart time.Time, hosts []string, sshHosts map[string]bool) (File, Result, bool) { + return foldOrigin(f, existed, sessionID, sessionStart, hosts, sshHosts) +} + +func foldOrigin(f File, existed bool, sessionID string, sessionStart time.Time, hosts []string, sshHosts map[string]bool) (File, Result, bool) { stamp := sessionStart.UTC().UnixMilli() out := f out.Hosts = maps.Clone(f.Hosts) @@ -92,6 +102,7 @@ func Fold(f File, existed bool, sessionID string, sessionStart time.Time, hosts next.FirstSeenSession, next.FirstSeenAtUnixMS = sessionID, stamp } next.LastSeenAtUnixMS = max(next.LastSeenAtUnixMS, stamp) + next.SSH = sshHosts[h] if !seen || next != prev { out.Hosts[h] = next changed = true @@ -206,7 +217,20 @@ func canonicalizeHosts(hosts map[string]entry) map[string]entry { out := make(map[string]entry, len(hosts)) for _, h := range keys { e := hosts[h] - canon, ok := host.Canonical(h) + // An entry marked SSH (entry.SSH) was named only through an + // ssh-family destination, never through a URL a client resolves, so + // it is re-keyed without the inet_aton fold (host.CanonicalName): + // folding "1234" to 0.0.4.210 here would do on every load exactly + // what fixing the ssh extraction path alone could not undo. A + // legacy entry with no field at all is SSH == false and keeps the + // fold, which is what TestUpdate_LegacyIPLiteralIsNotNovelAgain and + // TestLoad_MergesCollidingLegacyKeys pin for a file old enough to + // predate the distinction. + canonicalize := host.Canonical + if e.SSH { + canonicalize = host.CanonicalName + } + canon, ok := canonicalize(h) if !ok || !host.Shaped(canon) { continue } @@ -220,6 +244,10 @@ func canonicalizeHosts(hosts map[string]entry) map[string]entry { next.FirstSeenSession, next.FirstSeenAtUnixMS = e.FirstSeenSession, e.FirstSeenAtUnixMS } next.LastSeenAtUnixMS = max(lastSeen(prev), lastSeen(e)) + // Conservative once either contributor is SSH: a merged entry stays + // un-foldable rather than reverting to a fold neither spelling may + // have earned. + next.SSH = prev.SSH || e.SSH out[canon] = next } return out diff --git a/internal/baseline/ssh_origin_test.go b/internal/baseline/ssh_origin_test.go new file mode 100644 index 00000000..5d2c6c53 --- /dev/null +++ b/internal/baseline/ssh_origin_test.go @@ -0,0 +1,82 @@ +package baseline + +import ( + "testing" + "time" +) + +// TestUpdateOrigin_SSHAliasSurvivesRepeatedRenders: a numeric ssh alias +// folded by host.Canonical reads as a different host after one render and +// is gone, merged into the folded spelling, after a second -- fixing only +// the extraction side was not enough, because load's own canonicalizeHosts +// runs on every read and would refold it right back. UpdateOrigin's +// sshHosts marks the entry (entry.SSH) so canonicalizeHosts skips the fold +// for it, on this render and every one after. +func TestUpdateOrigin_SSHAliasSurvivesRepeatedRenders(t *testing.T) { + root := t.TempDir() + ssh := map[string]bool{"1234": true} + + // sess-1 establishes the baseline, so it reports "established" rather + // than novel hosts (Result.Established's own doc); sess-2 below is the + // one whose Novel answer this test is really about. + first := UpdateOrigin(root, "/proj", "sess-1", t0, []string{"1234"}, ssh) + if first.Err != nil { + t.Fatal(first.Err) + } + if !first.Established { + t.Fatalf("first render established = false, want true") + } + + // A second render, of a different session, reloads the file through + // canonicalizeHosts before folding anything new in. If that still + // folded a numeric alias, "1234" would already be "0.0.4.210" by the + // time this session's own, still-unfolded "1234" tried to match it. + second := UpdateOrigin(root, "/proj", "sess-2", t0.Add(time.Hour), []string{"1234"}, ssh) + if second.Err != nil { + t.Fatal(second.Err) + } + if len(second.Novel) != 0 { + t.Errorf("second render novel = %v, want empty: sess-2 named the same alias sess-1 already owns", second.Novel) + } + if second.Known != 1 { + t.Errorf("known = %d, want 1: one alias, not split into a folded and an unfolded spelling", second.Known) + } + + f, _, err := load(fileNameFor(t, root, "/proj"), "/proj") + if err != nil { + t.Fatal(err) + } + e, ok := f.Hosts["1234"] + if !ok { + t.Fatalf("hosts = %v, want the literal alias \"1234\", not its inet_aton fold", f.Hosts) + } + if !e.SSH { + t.Error("the surviving entry lost its SSH marker across a load") + } +} + +// TestUpdateOrigin_WireHostStillFolds: a numeric WIRE host -- sshHosts does +// not name it -- still folds and still migrates exactly as Update's own +// tests pin, because UpdateOrigin with every host absent from sshHosts is +// Update. +func TestUpdateOrigin_WireHostStillFolds(t *testing.T) { + root := t.TempDir() + writeLegacyBaseline(t, root, "/proj", map[string]entry{ + "167838211": {FirstSeenSession: "sess-0", FirstSeenAtUnixMS: t0.UnixMilli()}, + }) + + res := UpdateOrigin(root, "/proj", "sess-1", t0.Add(time.Hour), []string{"10.1.2.3"}, nil) + if len(res.Novel) != 0 { + t.Errorf("novel = %v, want empty: 167838211 and 10.1.2.3 are one host", res.Novel) + } + if res.Known != 1 { + t.Errorf("known = %d, want 1: the legacy key and the canonical one must merge", res.Known) + } +} + +// fileNameFor is the path Update/UpdateOrigin reads and writes for key under +// root, for a test that wants to load() the file directly afterwards. +func fileNameFor(t *testing.T, root, key string) string { + t.Helper() + return root + "/baseline/" + fileName(key) +} diff --git a/internal/host/host.go b/internal/host/host.go index f4f18e3c..7bd27f43 100644 --- a/internal/host/host.go +++ b/internal/host/host.go @@ -68,6 +68,31 @@ const maxName = 253 // the existence of. Refusing it means a credential cannot be carried even one // function deeper. func Canonical(raw string) (string, bool) { + return canonical(raw, true) +} + +// CanonicalName is Canonical without the inet_aton fold: every other +// transformation in Canonical's doc still applies -- case, port, the +// trailing dot, the length bound, an IPv6 literal's one spelling -- but a +// name made only of digits is returned as the name it is, not reinterpreted +// as the address curl or wget would read it as. +// +// Folding belongs to a URL authority, where it is a fact about what a real +// client does before it connects. It does not belong to an ssh, scp, sftp or +// rsync destination: ssh matches a Host alias in ~/.ssh/config against the +// exact text the command line named, before any resolution happens, and a +// numeric alias is ordinary there ("Host 1234" is ssh config nobody has to +// justify). Canonical("1234") -> "0.0.4.210", and Canonical("0") -> +// "0.0.0.0", which Loopback then reads as this machine -- right for a URL a +// client actually folds, wrong for an alias nothing resolved. CanonicalName +// is what the ssh-destination path (destinationHost) and this package's own +// Loopback use instead, so a destination this product deliberately never +// resolves (see destinationHost's doc) is not resolved here either. +func CanonicalName(raw string) (string, bool) { + return canonical(raw, false) +} + +func canonical(raw string, foldNumeric bool) (string, bool) { s := strings.TrimSpace(raw) if s == "" { return "", false @@ -110,13 +135,17 @@ func Canonical(raw string) (string, bool) { if !plausible(h) { return "", false } - return address(h) + return address(h, foldNumeric) } // address writes an IP literal h in its one spelling (see Canonical) and // returns any other host as it is. A bracket that does not hold an IPv6 // address, or a colon outside a bracket that does not make one, is refused. -func address(h string) (string, bool) { +// foldNumeric gates inet_aton only -- an IPv6 literal is still parsed to its +// one spelling either way, because a bracket or a bare double colon is +// unambiguously an address and carries none of the alias ambiguity inet_aton +// folding has (see CanonicalName). +func address(h string, foldNumeric bool) (string, bool) { inner, bracketed := h, strings.HasPrefix(h, "[") if bracketed { inner = h[1 : len(h)-1] @@ -128,8 +157,10 @@ func address(h string) (string, bool) { } return spell(a), true } - if a, ok := inetAton(h); ok { - return a.String(), true + if foldNumeric { + if a, ok := inetAton(h); ok { + return a.String(), true + } } return h, true } @@ -276,9 +307,27 @@ func Same(stored, canonical string) bool { // unspecified address, a connection to which reaches this machine. It is // decided on the parsed address, so every spelling of one -- // [0:0:0:0:0:0:0:1], 2130706433, 127.1 -- answers alike, where a list of -// spellings answered only for the spellings on it. +// spellings answered only for the spellings on it. The fold (Canonical, not +// CanonicalName) is wanted here: a URL authority of "2130706433" really does +// reach this machine, obfuscated decimal form or not, and the whole point is +// to catch that spelling, not just the dotted one. func Loopback(h string) bool { - c, ok := Canonical(h) + return loopback(Canonical(h)) +} + +// LoopbackName is Loopback without the inet_aton fold (host.CanonicalName, +// not Canonical): for an ssh destination, where "0" is as plausible a config +// alias as any other name and is never resolved by anything in this +// package. Loopback("0") folds to 0.0.0.0 and reads as this machine, which +// is right for a URL a client actually resolves and wrong for an alias +// nothing here resolved; LoopbackName("0") does not fold and is false. +func LoopbackName(h string) bool { + return loopback(CanonicalName(h)) +} + +// loopback is Loopback and LoopbackName's shared tail, given the result of +// whichever canonicalisation its caller already applied. +func loopback(c string, ok bool) bool { if !ok { return false } diff --git a/internal/host/host_test.go b/internal/host/host_test.go index 2a075e9d..ffc12784 100644 --- a/internal/host/host_test.go +++ b/internal/host/host_test.go @@ -65,6 +65,69 @@ func TestCanonical(t *testing.T) { } } +// TestCanonicalName_DoesNotFoldDigits: CanonicalName is Canonical's twin for +// an ssh destination, where a number is as plausible a config alias as any +// other name. Canonical("1234") folds to "0.0.4.210" and Canonical("0") +// folds to "0.0.0.0", which Loopback then reads as this machine -- right for +// a URL a client actually resolves, wrong for an alias nothing resolved. +// CanonicalName leaves every one of them as the name it was typed. +func TestCanonicalName_DoesNotFoldDigits(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"1234", "1234"}, + {"0", "0"}, + {"167838211", "167838211"}, + {"10.1.515", "10.1.515"}, + {"012.1.2.3", "012.1.2.3"}, + } { + got, ok := CanonicalName(tc.in) + if !ok || got != tc.want { + t.Errorf("CanonicalName(%q) = %q,%v, want %q,true", tc.in, got, ok, tc.want) + } + } + // A bracketed IPv6 literal is unambiguous either way: CanonicalName still + // writes it in its one spelling. + if got, ok := CanonicalName("[2606:4700:0:0:0:0:0:1111]"); !ok || got != "[2606:4700::1111]" { + t.Errorf("CanonicalName(ipv6) = %q,%v, want [2606:4700::1111],true", got, ok) + } + // Canonical, unchanged, still folds every one of these: CanonicalName is + // an addition, not a behaviour change to the function URL authorities use. + for _, tc := range []struct{ in, want string }{ + {"1234", "0.0.4.210"}, + {"0", "0.0.0.0"}, + {"167838211", "10.1.2.3"}, + } { + got, ok := Canonical(tc.in) + if !ok || got != tc.want { + t.Errorf("Canonical(%q) = %q,%v, want %q,true (unchanged)", tc.in, got, ok, tc.want) + } + } +} + +// TestLoopbackName_DoesNotFoldDigits: LoopbackName must not read a bare +// number as this machine, the same bug as CanonicalName's but observed +// through Loopback's decision rather than through the string it returns. +// Loopback, unchanged, still does: a URL authority of "0" really does reach +// this machine. +func TestLoopbackName_DoesNotFoldDigits(t *testing.T) { + for _, in := range []string{"0", "1234", "2130706433", "127.1"} { + if LoopbackName(in) { + t.Errorf("LoopbackName(%q) = true, want false: not a URL a client resolved", in) + } + } + for _, in := range []string{"0", "2130706433", "127.1"} { + if !Loopback(in) { + t.Errorf("Loopback(%q) = false, want true (unchanged)", in) + } + } + // What LoopbackName still catches: localhost and a real loopback literal, + // neither of which needed the fold to begin with. + for _, in := range []string{"localhost", "api.localhost", "127.0.0.1", "[::1]"} { + if !LoopbackName(in) { + t.Errorf("LoopbackName(%q) = false, want true", in) + } + } +} + // TestCanonical_IsIdempotent matters because the wire side canonicalises rows // that the declaration side may already have canonicalised, and a second pass // that changed the answer would make the two disagree on exactly the hosts that diff --git a/internal/report/destinations.go b/internal/report/destinations.go index ec9cdbd2..424a0bdd 100644 --- a/internal/report/destinations.go +++ b/internal/report/destinations.go @@ -91,6 +91,13 @@ type Novelty struct { // Hosts are this session's declared hosts that were not yet in the // project's baseline, sorted, excluding the ubiquitous list. Hosts []string `json:"hosts"` + // Evicted is how many hosts this render dropped from the baseline at its + // 4,096-host cap (baseline.Result.Evicted), least recently seen first. + // Without it, a host that returns after its own eviction reads as + // "never named before" rather than "named before, and the baseline + // aged it out" -- the same bytes, a different sentence. Omitted at + // zero, which is every render that never reaches the cap. + Evicted int `json:"evicted,omitempty"` } // clientPlaneHosts are the hosts the client contacts on its own behalf, @@ -160,8 +167,8 @@ func buildDestinations(run *store.Run, storeRoot string, forgotten func(host str ExecutedNotAsDeclared: executedNotAsDeclared(run), Rewritten: rewrittenCalls(run), } - d.Novelty = buildNovelty(run, storeRoot, - novelHostCandidates(append(append([]string{}, d.DeclaredHosts...), d.SSHHosts...), madeMCPCall(run))) + wire, ssh := novelHostCandidates(d.DeclaredHosts, d.SSHHosts, madeMCPCall(run)) + d.Novelty = buildNovelty(run, storeRoot, wire, ssh) return d } @@ -175,15 +182,28 @@ func buildDestinations(run *store.Run, storeRoot string, forgotten func(host str // api.anthropic.com a "new destination for this project" on the first session // of every project, which is the novelty line's most obvious way to make // itself worthless. -func novelHostCandidates(hosts []string, mcpAttributed bool) []string { - var out []string - for _, h := range hosts { - if host.Loopback(h) || clientPlaneHost(h, mcpAttributed) { - continue +// +// wire and ssh are filtered and returned separately, never merged: the +// baseline has to know which of this session's hosts came from an ssh +// destination (baseline.UpdateOrigin's sshHosts) to skip the inet_aton fold +// for them, and that is a fact only the caller still has at this point. The +// two also ask Loopback differently for the same reason -- host.Loopback +// folds a wire host's numeric forms before asking, which is right for a URL +// a client actually resolves; host.LoopbackName does not, because an ssh +// alias such as "0" is not a URL a client resolves at all, and folding it +// first would read a destination that named nothing as this machine. +func novelHostCandidates(wireHosts, sshHosts []string, mcpAttributed bool) (wire, ssh []string) { + filter := func(hosts []string, loopback func(string) bool) []string { + var out []string + for _, h := range hosts { + if loopback(h) || clientPlaneHost(h, mcpAttributed) { + continue + } + out = append(out, h) } - out = append(out, h) + return out } - return out + return filter(wireHosts, host.Loopback), filter(sshHosts, host.LoopbackName) } // buildNovelty consults the project baseline. @@ -192,7 +212,11 @@ func novelHostCandidates(hosts []string, mcpAttributed bool) []string { // rendering the report: a report run from a different directory must not key a // past session to the reader's project. A session with no recorded cwd has no // project, and novelty is unavailable rather than guessed. -func buildNovelty(run *store.Run, storeRoot string, hosts []string) Novelty { +// +// wireHosts and sshHosts are passed to baseline.UpdateOrigin separately so a +// host named only over ssh is marked (entry.SSH) rather than folded as an IP +// literal on every later load. +func buildNovelty(run *store.Run, storeRoot string, wireHosts, sshHosts []string) Novelty { n := Novelty{Hosts: []string{}} cwd := runCWD(run) if cwd == "" { @@ -213,8 +237,13 @@ func buildNovelty(run *store.Run, storeRoot string, hosts []string) Novelty { return n } - res := baseline.Update(storeRoot, baseline.Key(cwd), run.SessionID(), - time.UnixMilli(runStartMS(run)), hosts) + sshSet := make(map[string]bool, len(sshHosts)) + for _, h := range sshHosts { + sshSet[h] = true + } + hosts := append(append([]string{}, wireHosts...), sshHosts...) + res := baseline.UpdateOrigin(storeRoot, baseline.Key(cwd), run.SessionID(), + time.UnixMilli(runStartMS(run)), hosts, sshSet) if res.Err != nil { // Deliberately not the error text: it carries the baseline path, and a // path carries someone's directory names. @@ -224,6 +253,7 @@ func buildNovelty(run *store.Run, storeRoot string, hosts []string) Novelty { n.Available = true n.Established = res.Established n.KnownHosts = res.Known + n.Evicted = res.Evicted if res.Novel != nil { n.Hosts = res.Novel } @@ -364,18 +394,24 @@ func sshDeclared(run *store.Run) []string { // // Canonicalised before forgotten is asked, the same correction // declaredHostsFiltered applies and for the same reason: forget --host -// records its digest under the CANONICAL spelling, never the one a +// records its digest under the canonical spelling, never the one a // declaration happens to hold, so asking forgotten about the stored spelling // let a legacy or differently-cased ssh host -- "GitHub.com" where today's // extraction writes "github.com" -- survive a forget of its canonical name // and kept it out of the one spelling every current declaration uses. A host // that no longer canonicalises, or that host.Shaped now refuses, is dropped // rather than carried forward under whatever it used to be. +// +// host.CanonicalName, not Canonical: these are ssh destinations, and an +// ssh-config alias made only of digits is not a URL authority a client +// folds (host.CanonicalName's doc). Store.ForgetHost records a forget's +// digest under both spellings for exactly this reason, so a forget of +// either still finds this one. func sshDeclaredFiltered(run *store.Run, forgotten func(host string, declaredAtMS int64) bool) []string { set := map[string]bool{} for _, d := range run.Declarations { for _, h := range d.SSHHosts { - canon, ok := host.Canonical(h) + canon, ok := host.CanonicalName(h) if !ok || !host.Shaped(canon) { continue } diff --git a/internal/report/destinations_test.go b/internal/report/destinations_test.go index b73bade8..b0d8c893 100644 --- a/internal/report/destinations_test.go +++ b/internal/report/destinations_test.go @@ -288,7 +288,8 @@ func TestClientPlane_EveryViewAppliesTheOneRule(t *testing.T) { for _, mcp := range []bool{false, true} { for _, h := range hosts { want := clientPlaneHost(h, mcp) - if got := !slices.Contains(novelHostCandidates([]string{h}, mcp), h); got != want { + wire, _ := novelHostCandidates([]string{h}, nil, mcp) + if got := !slices.Contains(wire, h); got != want { t.Errorf("mcp session %v, %s: left out of the novelty candidates %v, the rule says %v", mcp, h, got, want) } trail := trailWith(nono.Event{Host: h, Port: 443, Decision: nono.DecisionAllow, Mode: "connect"}) diff --git a/internal/report/rubric_lld4_test.go b/internal/report/rubric_lld4_test.go index 2aaabb3b..243ad56f 100644 --- a/internal/report/rubric_lld4_test.go +++ b/internal/report/rubric_lld4_test.go @@ -11,11 +11,11 @@ import ( // 2130706433 and api.localhost were offered to the baseline as candidates // for "new destination" while [::1] and 127.0.0.1 were not. func TestRubricLLD4_LoopbackSpellingsAreNotNovel(t *testing.T) { - got := novelHostCandidates([]string{ + got, _ := novelHostCandidates([]string{ "localhost", "127.0.0.1", "[::1]", "0.0.0.0", "[0:0:0:0:0:0:0:1]", "127.0.0.2", "2130706433", "api.localhost", "[::]", "api.anthropic.com", "example.com", "10.1.2.3", - }, false) + }, nil, false) if want := []string{"example.com", "10.1.2.3"}; !reflect.DeepEqual(got, want) { t.Errorf("novelty candidates %q, want %q", got, want) } diff --git a/internal/report/text_destinations.go b/internal/report/text_destinations.go index bcfa3507..7ae8068d 100644 --- a/internal/report/text_destinations.go +++ b/internal/report/text_destinations.go @@ -64,4 +64,11 @@ func writeNovelty(b *bytes.Buffer, n Novelty) { fmt.Fprintf(b, " not yet in this project's baseline: %s\n", list(n.Hosts)) fmt.Fprintf(b, " (added to this project's baseline by this session; %d hosts known)\n", n.KnownHosts) } + if n.Evicted > 0 { + // Without this a host that comes back after eviction at the 4,096 + // cap (baseline.Result.Evicted) reads as "never named before" -- + // the same words a genuinely new host gets -- rather than "named + // before, and the baseline aged it out to make room". + fmt.Fprintf(b, " (%d host%s aged out of the baseline at its cap this render)\n", n.Evicted, plural(n.Evicted)) + } } diff --git a/internal/report/tru57_sections_test.go b/internal/report/tru57_sections_test.go index 199a2b71..01dcb60c 100644 --- a/internal/report/tru57_sections_test.go +++ b/internal/report/tru57_sections_test.go @@ -26,7 +26,7 @@ var allowlist = map[string]keySet{ "destinations": { "declared_hosts": nil, "ssh_hosts": nil, "executed_not_as_declared": nil, "rewritten": {"tool_use_id": nil, "tool_name": nil, "program": nil, "verb_class": nil}, - "novelty": {"available": nil, "reason": nil, "established": nil, "known_hosts": nil, "hosts": nil}, + "novelty": {"available": nil, "reason": nil, "established": nil, "known_hosts": nil, "hosts": nil, "evicted": nil}, }, "nono": { "configured": nil, "observed": nil, "reason": nil, "allowed": nil, "denied": nil, @@ -56,6 +56,7 @@ var allowlist = map[string]keySet{ // there and no program key on its link. var omittable = map[string]bool{ "destinations.novelty.reason": true, + "destinations.novelty.evicted": true, "nono.reason": true, "chains.prompts.links.program": true, "chains.unattributed.program": true, @@ -133,8 +134,12 @@ func TestTRU57_AMarshalledReportCarriesExactlyTheAllowlistedKeys(t *testing.T) { // non-Bash call and a healthy path; filled, a Bash call and a stated reason. func sectionFixture(filled string) Session { link := Link{Program: filled} + evicted := 0 + if filled != "" { + evicted = 1 + } return Session{ - Destinations: Destinations{Rewritten: []Rewritten{{}}, Novelty: Novelty{Reason: filled}}, + Destinations: Destinations{Rewritten: []Rewritten{{}}, Novelty: Novelty{Reason: filled, Evicted: evicted}}, Nono: Nono{Reason: filled}, Families: FamilyCoverage{Families: []Family{{}}}, Chains: Chains{ diff --git a/internal/shape/hosts.go b/internal/shape/hosts.go index 838476ef..48ba95e3 100644 --- a/internal/shape/hosts.go +++ b/internal/shape/hosts.go @@ -183,6 +183,19 @@ func sshCommandHosts(text string) (out []string, cut bool) { for rest, budget := text, maxTokens; rest != "" && budget > 0; { var line string line, rest, _ = strings.Cut(rest, "\n") + // sshLineHosts's own lexer knows nothing of the budget shared across + // lines -- it always reads up to the full maxTokens -- so a line + // reached with almost none of the shared budget left could still + // cost a whole line's worth of tokenizing: about 2x the stated bound + // in total, measured. bytesPerToken is already how a token is priced + // in bytes (RES-9's own derivation); capping this line's bytes at + // the budget actually left caps the tokens it can cost the same way, + // at the price of a cut landing mid-byte on the rare line that + // crosses this window, no worse than the token bound already does. + if window := budget * bytesPerToken; len(line) > window { + line = line[:window] + cut = true + } hosts, read, lineCut := sshLineHosts(line) out = append(out, hosts...) budget -= max(read, 1) @@ -364,7 +377,7 @@ func destinationHost(tok string, requireRemote bool) (string, bool) { if requireRemote && !strings.HasPrefix(rest[end+1:], ":") { return "", false } - return host.Canonical(rest[:end+1]) + return host.CanonicalName(rest[:end+1]) } hostPart := rest @@ -405,8 +418,10 @@ func destinationHost(tok string, requireRemote bool) (string, bool) { // cannot see. It is recorded as the command named it and never resolved: // resolving would mean reading ~/.ssh/config, which is a file this package // is not allowed to open, and guessing would put a name in the store that - // the user never typed. - return host.Canonical(hostPart) + // the user never typed. host.CanonicalName, not Canonical, for the same + // reason stated there: `ssh 1234` names the alias "1234", not the address + // inet_aton would read it as, because nothing here resolved it. + return host.CanonicalName(hostPart) } // collect finds every hostname introduced by one of the given prefixes in diff --git a/internal/shape/hosts_test.go b/internal/shape/hosts_test.go index 4b972899..d186e172 100644 --- a/internal/shape/hosts_test.go +++ b/internal/shape/hosts_test.go @@ -255,6 +255,10 @@ func TestSSHDestinations(t *testing.T) { {name: "bare host colon, no user", cmd: "scp host.example.com:/etc/f .", wantSSH: []string{"host.example.com"}}, {name: "ssh config alias", cmd: "ssh myserver", wantSSH: []string{"myserver"}, why: "recorded as named and never resolved: resolving would mean reading ~/.ssh/config"}, + {name: "numeric ssh config alias", cmd: "ssh 1234", wantSSH: []string{"1234"}, + why: "host.Canonical's inet_aton fold would read this as 0.0.4.210; it is an alias, never resolved"}, + {name: "ssh config alias of 0", cmd: "ssh 0", wantSSH: []string{"0"}, + why: "host.Canonical folds \"0\" to 0.0.0.0, which Loopback then reads as this machine"}, // What must NOT be recorded. {name: "remote command is not a host", cmd: "ssh h.example.com ls /etc", wantSSH: []string{"h.example.com"}, @@ -429,6 +433,39 @@ func TestSSHDestinationsAcrossLines(t *testing.T) { } } +// TestSSHCommandHosts_LineBudgetIsTheRemainder: sshCommandHosts's shared +// budget across lines only decides whether the NEXT line gets read at all; +// each line, once started, is lexed by sshLineHosts up to the full maxTokens +// regardless of how little budget is actually left, so a line near the end +// of a multi-line command can still cost a full line's worth of tokenizing +// with almost nothing left to spend. +// +// The filler line alone already costs close to maxTokens tokens' worth of +// allocation -- that is legitimate, budgeted work, not the bug -- so the +// assertion is on the DELTA a trailing 6 MB line adds on top of it with +// only 2 tokens of budget left, not on the total. +func TestSSHCommandHosts_LineBudgetIsTheRemainder(t *testing.T) { + // One short word per token, enough of them to leave the shared budget at + // 2 tokens (sshLineHosts counts "x" and the line's own single word as + // one token each here, well under sshDestPrograms so no host is found, + // which is irrelevant to this test -- only the token cost matters). + filler := strings.Repeat("x ", maxTokens-2) + text := filler + "\n" + strings.Repeat("ssh a;", 1<<20) + fillerOnly := allocated(func() { sshCommandHosts(filler) }) + withTrailer := allocated(func() { sshCommandHosts(text) }) + // Without the fix, the trailing line is lexed to the full maxTokens + // regardless of the 2 tokens of budget left, which costs about as much + // again as the filler line itself -- roughly doubling the total, the "2x + // the bound" the trailing line's own lexer has no notion of sharing. + // 256 KiB is generous for reading only the ~536 bytes (2 tokens' + // worth, bytesPerToken) the remaining budget actually allows. + if delta := int64(withTrailer) - int64(fillerOnly); delta > 256<<10 { + t.Errorf("a trailing 6 MB line with 2 tokens of budget left added %d KiB on top of the "+ + "filler line's own %d KiB; want at most 256 KiB, not a full line's worth of lexing", + delta>>10, fillerOnly>>10) + } +} + // TestSSHDestinationRefusesACredential is CWE-522 on the positional path. // // A destination carrying a password is refused, not stripped. Splitting at diff --git a/internal/store/gaps.go b/internal/store/gaps.go index be443988..9a95a2c4 100644 --- a/internal/store/gaps.go +++ b/internal/store/gaps.go @@ -456,6 +456,16 @@ type declHead struct { // host's, the spelling the sandbox trail carries. A value Canonical refuses // names no destination at all and is refused here, before anything is // written. +// +// A SECOND digest is recorded too, under host.CanonicalName -- the same +// spelling without the inet_aton fold -- when it differs from the first. +// An ssh declaration is stored and read back in that unfolded spelling +// (host.CanonicalName's doc), so "forget --host 1234" against an ssh alias +// named exactly that way would otherwise record a digest only the wire +// side's folded spelling ever matches, and the alias would never read as +// forgotten. The two spellings agree on everything that is not a bare +// number, so this is a second write only for the inputs where they can +// differ at all. func (s *Store) ForgetHost(raw string, now time.Time) ([]Gap, error) { name, ok := host.Canonical(raw) if !ok { @@ -471,6 +481,11 @@ func (s *Store) ForgetHost(raw string, now time.Time) ([]Gap, error) { if err := s.recordForgottenHost(s.HostDigest(name), now); err != nil { return nil, err } + if unfolded, ok := host.CanonicalName(raw); ok && unfolded != name { + if err := s.recordForgottenHost(s.HostDigest(unfolded), now); err != nil { + return nil, err + } + } return s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, name, now) }) } diff --git a/test/mutation/mutants/NONO_nono.py b/test/mutation/mutants/NONO_nono.py index 116d6297..6a36d990 100644 --- a/test/mutation/mutants/NONO_nono.py +++ b/test/mutation/mutants/NONO_nono.py @@ -193,8 +193,8 @@ # error paths is swallowed (review of #53): each of these survived every # related test. m("NONO the store-level forget record lands after the runs", "internal/store/gaps.go", - "\tif err := s.recordForgottenHost(s.HostDigest(name), now); err != nil {\n\t\treturn nil, err\n\t}\n\treturn s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, name, now) })", - "\tgaps, err := s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, name, now) })\n\tif err != nil {\n\t\treturn gaps, err\n\t}\n\tif err := s.recordForgottenHost(s.HostDigest(name), now); err != nil {\n\t\treturn nil, err\n\t}\n\treturn gaps, nil", + "\tif err := s.recordForgottenHost(s.HostDigest(name), now); err != nil {\n\t\treturn nil, err\n\t}\n\tif unfolded, ok := host.CanonicalName(raw); ok && unfolded != name {\n\t\tif err := s.recordForgottenHost(s.HostDigest(unfolded), now); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, name, now) })", + "\tgaps, err := s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, name, now) })\n\tif err != nil {\n\t\treturn gaps, err\n\t}\n\tif err := s.recordForgottenHost(s.HostDigest(name), now); err != nil {\n\t\treturn nil, err\n\t}\n\tif unfolded, ok := host.CanonicalName(raw); ok && unfolded != name {\n\t\tif err := s.recordForgottenHost(s.HostDigest(unfolded), now); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn gaps, nil", "TestForgetHost_AForgetThatFails") m("NONO an unreadable store-level forget file reads as nothing forgotten", "internal/store/gaps.go", "\ttimes, err := s.forgottenDigestTimes()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(times) == 0 {\n\t\t// A predicate", @@ -211,8 +211,9 @@ # candidates are the declared hosts, a list of names. Re-anchored again when # loopback came to be decided on the parsed address (host.Loopback). m("NONO the novelty candidates drop the MCP transport on an MCP session", "internal/report/destinations.go", - "\t\tif host.Loopback(h) || clientPlaneHost(h, mcpAttributed) {", "\t\tif host.Loopback(h) || clientPlaneHosts[h] {", + "\t\t\tif loopback(h) || clientPlaneHost(h, mcpAttributed) {\n", "\t\t\tif loopback(h) || clientPlaneHosts[h] {\n", "TestClientPlane_EveryView") m("NONO the session's MCP calls never reach the novelty candidates", "internal/report/destinations.go", - "madeMCPCall(run)))", "false))", + "\twire, ssh := novelHostCandidates(d.DeclaredHosts, d.SSHHosts, madeMCPCall(run))\n", + "\twire, ssh := novelHostCandidates(d.DeclaredHosts, d.SSHHosts, false)\n", "TestNovelty_TheMCPTransport") diff --git a/test/mutation/mutants/NOV_ssh-candidates.py b/test/mutation/mutants/NOV_ssh-candidates.py index acbcba40..955cabfc 100644 --- a/test/mutation/mutants/NOV_ssh-candidates.py +++ b/test/mutation/mutants/NOV_ssh-candidates.py @@ -4,6 +4,6 @@ # all, and nothing here still observes anything, so there is no reason left # to leave it out. m("destinations feeds only https hosts to the novelty baseline", "internal/report/destinations.go", - "novelHostCandidates(append(append([]string{}, d.DeclaredHosts...), d.SSHHosts...), madeMCPCall(run)))", - "novelHostCandidates(d.DeclaredHosts, madeMCPCall(run)))", + "\twire, ssh := novelHostCandidates(d.DeclaredHosts, d.SSHHosts, madeMCPCall(run))\n", + "\twire, ssh := novelHostCandidates(d.DeclaredHosts, nil, madeMCPCall(run))\n", "TestNovelty_SSHHostsAreCandidatesToo") diff --git a/test/mutation/mutants/REC-32_reader-limits.py b/test/mutation/mutants/REC-32_reader-limits.py index 2401a553..9a02ccab 100644 --- a/test/mutation/mutants/REC-32_reader-limits.py +++ b/test/mutation/mutants/REC-32_reader-limits.py @@ -54,7 +54,9 @@ # that can end in ErrForgetIncomplete, not only after a pass that finished. m("REC-32 forget --host records the host only after every run was rewritten", "internal/store/gaps.go", "\tif err := s.recordForgottenHost(s.HostDigest(name), now); err != nil {\n\t\treturn nil, err\n\t}\n" + "\tif unfolded, ok := host.CanonicalName(raw); ok && unfolded != name {\n\t\tif err := s.recordForgottenHost(s.HostDigest(unfolded), now); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n" "\treturn s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, name, now) })\n", + "\tif unfolded, ok := host.CanonicalName(raw); ok && unfolded != name {\n\t\tif err := s.recordForgottenHost(s.HostDigest(unfolded), now); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n" "\tgaps, err := s.forgetEach(func(dir string) (*Gap, error) { return s.forgetHostInRun(dir, name, now) })\n" "\tif err != nil {\n\t\treturn gaps, err\n\t}\n\treturn gaps, s.recordForgottenHost(s.HostDigest(name), now)\n", "TestForgetHost_APartialForgetSaysLaterReportsLeaveItOutOnlyWithItsRecord") diff --git a/test/mutation/mutants/RUBRIC_lld-11_baseline-canonical.py b/test/mutation/mutants/RUBRIC_lld-11_baseline-canonical.py index 3e9cf6fa..e8154719 100644 --- a/test/mutation/mutants/RUBRIC_lld-11_baseline-canonical.py +++ b/test/mutation/mutants/RUBRIC_lld-11_baseline-canonical.py @@ -7,8 +7,8 @@ "\tf.Hosts = canonicalizeHosts(f.Hosts)\n\treturn f, true, nil\n", "\treturn f, true, nil\n", "TestUpdate_LegacyIPLiteralIsNotNovelAgain|TestLoad_") m("RUBRIC lld-11 an unshaped legacy key is kept", "internal/baseline/fold.go", - "\t\tcanon, ok := host.Canonical(h)\n\t\tif !ok || !host.Shaped(canon) {\n\t\t\tcontinue\n\t\t}\n", - "\t\tcanon, ok := host.Canonical(h)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n", "TestLoad_RefusesAnUnshapedLegacyKey") + "\t\tcanon, ok := canonicalize(h)\n\t\tif !ok || !host.Shaped(canon) {\n\t\t\tcontinue\n\t\t}\n", + "\t\tcanon, ok := canonicalize(h)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n", "TestLoad_RefusesAnUnshapedLegacyKey") m("RUBRIC lld-11 a colliding legacy key loses the later LastSeenAtUnixMS", "internal/baseline/fold.go", "\t\tnext.LastSeenAtUnixMS = max(lastSeen(prev), lastSeen(e))\n", "\t\tnext.LastSeenAtUnixMS = lastSeen(prev)\n", "TestLoad_MergesCollidingLegacyKeys") diff --git a/test/mutation/mutants/RUBRIC_lld-11b_forgotten-check-canonical.py b/test/mutation/mutants/RUBRIC_lld-11b_forgotten-check-canonical.py index b5680016..50b30e5b 100644 --- a/test/mutation/mutants/RUBRIC_lld-11b_forgotten-check-canonical.py +++ b/test/mutation/mutants/RUBRIC_lld-11b_forgotten-check-canonical.py @@ -21,9 +21,9 @@ "TestSeam_ForgetFindsADeclarationStoredInALegacySpelling") m("RUBRIC lld-11b sshDeclaredFiltered asks forgotten about the stored spelling", "internal/report/destinations.go", "for _, h := range d.SSHHosts {\n" - "\t\t\tcanon, ok := host.Canonical(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n" + "\t\t\tcanon, ok := host.CanonicalName(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n" "\t\t\tif forgotten != nil && forgotten(canon, d.RecordedAtMS) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[canon] = true\n", "for _, h := range d.SSHHosts {\n" - "\t\t\tcanon, ok := host.Canonical(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n" + "\t\t\tcanon, ok := host.CanonicalName(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n" "\t\t\tif forgotten != nil && forgotten(h, d.RecordedAtMS) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[canon] = true\n", "TestSeam_ForgetFindsAnSSHDeclarationStoredInALegacySpelling") diff --git a/test/mutation/mutants/RUBRIC_lld-4_spellings.py b/test/mutation/mutants/RUBRIC_lld-4_spellings.py index 0a6ee4f1..18a0b61a 100644 --- a/test/mutation/mutants/RUBRIC_lld-4_spellings.py +++ b/test/mutation/mutants/RUBRIC_lld-4_spellings.py @@ -19,11 +19,9 @@ m("RUBRIC lld-4 a name under .localhost is not loopback", "internal/host/host.go", "\tif c == \"localhost\" || strings.HasSuffix(c, \".localhost\") {", "\tif c == \"localhost\" {", "TestRubricLLD4_") m("RUBRIC lld-4 the report filters loopback by its old spellings", "internal/report/destinations.go", - "\t\tif host.Loopback(h) || clientPlaneHost(h, mcpAttributed) {", - "\t\tif h == \"localhost\" || h == \"127.0.0.1\" || h == \"[::1]\" || h == \"0.0.0.0\" || clientPlaneHost(h, mcpAttributed) {", + "\t\t\tif loopback(h) || clientPlaneHost(h, mcpAttributed) {\n", + "\t\t\tif h == \"localhost\" || h == \"127.0.0.1\" || h == \"[::1]\" || h == \"0.0.0.0\" || clientPlaneHost(h, mcpAttributed) {\n", "TestRubricLLD4_") -IMPORTS["RUBRIC lld-4 the report filters loopback by its old spellings"] = ("internal/report/destinations.go", - "\t\"github.com/altrace-dev-role/rashomon/internal/host\"\n", "") # forget --host asks in the recorded spelling (fix list 30, the forget half): # `curl https://167838211/x` records 10.1.2.3, and a forget that compared the # typed value, or a recorded one as stored, left the host on disk while From a38fe762e2d91107a928e5a739bb1291d7ed96bb Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Sun, 18 Oct 2026 09:58:43 -0700 Subject: [PATCH 31/50] shape: a blank inside one quoted word is not a word break tokenEstimate counts words by watching for blanks, but it didn't track whether it was inside a '...' or "..." span. `echo 'a a a a ...'` is one quoted word no matter how many blanks it holds, but the estimator counted every blank inside the quotes as its own word break, so a single long quoted argument could inflate the token count far past what the real tokenizer would ever produce for the same line. Track the enclosing quote byte (0 when not inside one) and only treat a blank as a word break when it isn't. A backslash inside a double-quoted span still escapes the next byte; inside a single-quoted span nothing does, matching shell's own quoting rules. --- internal/shape/tokenize.go | 25 ++++++++++++++++++++++++- internal/shape/tokenize_test.go | 24 ++++++++++++++++++++++++ test/perf/pathological/pathological.go | 4 ++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/internal/shape/tokenize.go b/internal/shape/tokenize.go index a446184d..972a95e5 100644 --- a/internal/shape/tokenize.go +++ b/internal/shape/tokenize.go @@ -579,13 +579,36 @@ func (l *lexer) plain(i int) { // escaped blank ends no word: 8 MiB of `\ ` is one word, and counted as // millions the line was given a slice for the bound's worth of tokens in // each of its readings. +// +// A blank inside a single or double quote ends no word either, for the same +// reason: it tracks open/close, not full shell quoting ($(...) nesting +// inside double quotes among them), because this is an upper bound and a +// miscount toward UNDER-estimating costs nothing past the grow lex already +// does for a short line. One 8 MiB single-quoted string full of spaces, with +// no tracking, estimated maxTokens+1 words for what lex reads as one, and +// paid for that slice on every one of a line's several readings. func tokenEstimate(s string) int { n, inWord := 1, false + quote := byte(0) // 0, or the ' or " this position is inside for i := 0; i < len(s) && n <= maxTokens; i++ { - switch c := s[i]; { + c := s[i] + if quote != 0 { + switch { + case c == quote: + quote = 0 + case quote == '"' && c == '\\': // only a double quote reads an escape + i++ + } + inWord = true + continue + } + switch { case c == '\\': // the byte it escapes is the word's, a blank included i++ inWord = true + case c == '\'' || c == '"': + quote = c + inWord = true case c == ' ' || c == '\t' || c == '\n' || c == '\r': if inWord { n++ diff --git a/internal/shape/tokenize_test.go b/internal/shape/tokenize_test.go index 3d379b3c..2aa66932 100644 --- a/internal/shape/tokenize_test.go +++ b/internal/shape/tokenize_test.go @@ -2,6 +2,7 @@ package shape import ( "slices" + "strings" "testing" ) @@ -364,3 +365,26 @@ func TestTokenizeTicks(t *testing.T) { } } } + +// TestTokenEstimate_BlanksInsideOneQuoteAreNotWordBreaks: tokenEstimate +// counts a blank as ending a word without regard to whether the lexer is +// inside a quote, so one 8 MiB single-quoted string full of spaces makes it +// preallocate a slice of maxTokens+1 tokens -- the estimate's own ceiling -- +// for a line whose real word count is tiny: the shell reads the whole +// quoted string as one word. lex itself is unaffected (it tracks quoting +// correctly); only the estimate used to size its initial allocation is +// wrong, and wrong toward over-allocating, not under. +func TestTokenEstimate_BlanksInsideOneQuoteAreNotWordBreaks(t *testing.T) { + s := "echo '" + strings.Repeat("a ", 100_000) + "'" + toks, err := tokenizeShape(s) + if err != nil { + t.Fatalf("tokenizeShape(...): %v", err) + } + if len(toks) > 4 { + t.Fatalf("premise: %d real tokens, want a handful for one program and one quoted word", len(toks)) + } + if got := tokenEstimate(s); got > 10*len(toks)+10 { + t.Errorf("tokenEstimate(...) = %d against %d real tokens; a blank inside a quote must not "+ + "count as a word break", got, len(toks)) + } +} diff --git a/test/perf/pathological/pathological.go b/test/perf/pathological/pathological.go index da9f44ec..b9c34e49 100644 --- a/test/perf/pathological/pathological.go +++ b/test/perf/pathological/pathological.go @@ -38,5 +38,9 @@ func Lines() []Line { {"urls", "curl", " https://a.b/", ""}, {"git@ scheme runs", "", "git@", ""}, {"unterminated bracket hosts", "", "https://[", ""}, + // Appended rather than inserted beside the other quoted-blanks rows: + // cmd/rashomon's own premise check pins this table's indices, and a + // row inserted in the middle would shift them silently. + {"blanks inside one quote", "echo '", "a ", "'"}, } } From d672c2816c7cdee4a2377067177d2b7cbf9031d3 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Sun, 18 Oct 2026 09:58:56 -0700 Subject: [PATCH 32/50] cmd/rashomon, ci: RES-29's peak-RSS bound is per platform, and runs without -race The 8 MiB git@-scheme-runs case measured 65.1-65.3 MiB of peak RSS on darwin/arm64 against a single 64 MiB bound shared with linux, for identical Go allocations -- the same program costs a few MiB more RSS on darwin than on linux/amd64, which measured 54.78 MiB on the same input under Docker. The bound is now runtime.GOOS-conditional: 64 MiB on linux, 76 MiB on darwin, with the measured numbers in the comment so the next person who trips it knows whether it's a real regression or the usual platform gap. Separately, RES-29's actual RSS and deadline assertions don't run under -race -- the race detector's shadow memory inflates RSS the same way it inflates CPU time, so the test logs and skips under -race rather than asserting a bound neither number means anything under. The `test` job runs `go test ./... -race`, which means this bound was never actually checked in CI at all. Added a dedicated step to the perf-linux job that runs this one test without -race, so linux/amd64's own number is the one actually compared against the bound somewhere. --- .github/workflows/ci.yaml | 10 ++++++++++ cmd/rashomon/res29_test.go | 26 +++++++++++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 69c8c2a4..c91f7b58 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -428,6 +428,16 @@ jobs: RASHOMON_PERF_REQUIRE_ALL: "1" run: go test -tags perf ./test/perf -run 'TestRES2_|TestRES28' -count=1 -v + # RES-29's own peak-RSS bound (cmd/rashomon/res29_test.go) does not + # apply under -race (its own comment explains why -- the detector's + # shadow memory inflates RSS the same way it inflates CPU time), so the + # `test` job's `go test ./... -race` never actually checks it: every + # row logs and skips. This is the only place it is checked anywhere, + # and the only place linux/amd64's own number (not darwin's) is the one + # compared. + - name: RES-29 peak RSS (no race detector) + run: go test ./cmd/rashomon -run '^TestRES29_PathologicalInputsStayBounded$' -count=1 -v + perf-macos: name: Perf gates (macos) runs-on: macos-latest diff --git a/cmd/rashomon/res29_test.go b/cmd/rashomon/res29_test.go index a0ad1dd9..5fe81ec8 100644 --- a/cmd/rashomon/res29_test.go +++ b/cmd/rashomon/res29_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "os/exec" + "runtime" "strings" "testing" "time" @@ -38,8 +39,9 @@ func res29Payload(t *testing.T, size int, prefix, unit, suffix string) []byte { // TestRES29_PathologicalInputsStayBounded: each pathological input the shape // of a call reads exits 0 inside the hook's hard stop at peak RSS of at most -// 64 MiB, and still records the call. It runs the hook entry in a child -// process, against a started session, as the installed binary runs it. +// rssMiB (64 on linux, 76 on darwin -- see its own comment), and still +// records the call. It runs the hook entry in a child process, against a +// started session, as the installed binary runs it. // // The rows are RES-29's that the shape of a call owns -- 8 MiB of short words // (the token bound, RES-9; the first row, which the RES-9 mutant turns red), @@ -71,8 +73,22 @@ func TestRES29_PathologicalInputsStayBounded(t *testing.T) { // (plugin/hooks/hooks.json), so a hook this slow would miss that // external timeout too, not just this test. deadline = 4 * time.Second - rssMiB = 64 ) + // rssMiB is platform-specific, not one portable number: measured + // 2026-10-18 on the git@ scheme runs row, the one that runs this test + // closest to its ceiling, the same child binary peaks at 54.78 MiB on + // linux/amd64 (test/perf/peakrss, docker/colima) against 65.11-65.25 MiB + // on darwin/arm64 for the identical payload -- a ~19% gap in resident + // memory for the same allocations, which Go's runtime costs more of per + // allocation on darwin/arm64, not more work done. A single bound tight + // enough to mean something on linux was flaky on darwin by design, not + // by regression; a bound loose enough to never flake on darwin stopped + // meaning anything on linux, where CI actually runs (-race, go1.25.8). + // Each platform keeps its own margin instead. + rssMiB := 64.0 + if runtime.GOOS == "darwin" { + rssMiB = 76 + } type row struct { name string payload func(*testing.T) []byte @@ -144,9 +160,9 @@ func TestRES29_PathologicalInputsStayBounded(t *testing.T) { case reaper == nil: t.Log("this platform reports no peak RSS for a child; the bound is not applied") case raceEnabled: - t.Logf("race detector on: %.2f MiB, the %d MiB bound is not applied to an instrumented child", rss, rssMiB) + t.Logf("race detector on: %.2f MiB, the %.0f MiB bound is not applied to an instrumented child", rss, rssMiB) case rss > rssMiB: - t.Errorf("hook peaked at %.2f MiB, over %d MiB", rss, rssMiB) + t.Errorf("hook peaked at %.2f MiB, over %.0f MiB", rss, rssMiB) default: t.Logf("hook: %v, peak RSS %.2f MiB", took.Round(time.Millisecond), rss) } From 24dea4536c0def4ea8a628d42cce97b06d6c7151 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Sun, 18 Oct 2026 09:59:08 -0700 Subject: [PATCH 33/50] shape, hook: startDirectory reuses Derive's own reading of the command startDirectory called shape.LeadingDirectory on its own to get the leading cd targets, which decodes and tokenizes the command a second time -- Derive already does that once to compute the rest of the shape, and Hosts does it again for the host list. The budget this package derives (RES-9) assumes three readings of a line; the hook entry was doing at least four. Added shape.DeriveWithDirs, which is Derive's shared body (now deriveCut) with the leading-cd directories it already read returned alongside the shape and the cut flag. The hook calls it once and passes the directories straight into startDirectory, which is now a pure fold with no decoding of its own. Derive and DeriveCut are unchanged thin wrappers over the same body, so the test/perf/payload and post-hook callers that only want the shape don't pay for directories they don't use. --- internal/hook/handle.go | 36 ++++++++++++++++++++----------- internal/hook/handle_test.go | 5 ++++- internal/hook/res10_test.go | 22 +++++++++++++++---- internal/shape/shape.go | 20 +++++++++++++++-- test/mutation/mutants/TB_shape.py | 8 +++---- 5 files changed, 68 insertions(+), 23 deletions(-) diff --git a/internal/hook/handle.go b/internal/hook/handle.go index 1dc1affc..c04f3202 100644 --- a/internal/hook/handle.go +++ b/internal/hook/handle.go @@ -132,10 +132,16 @@ func (h *Handler) Capture(in io.Reader) error { TranscriptPath: p.TranscriptPath, PermissionMode: p.PermissionMode, ToolName: p.ToolName, - Shape: shape.Derive(p.ToolName, p.ToolInput, h.st.Key()), - CWDDigest: shape.CWDDigest(h.st.Key(), startDirectory(p.CWD, p.ToolName, p.ToolInput)), RulesVersion: shape.RulesVersion, } + // DeriveWithDirs, not Derive: it returns the same shape and the same cut + // decision, plus the leading-cd directories (shape.LeadingDirectory's own + // answer) it already read the command to compute. startDirectory folds + // those directly instead of decoding and tokenizing the command a second + // time to get them again. + var dirs []string + decl.Shape, _, dirs = shape.DeriveWithDirs(p.ToolName, p.ToolInput, h.st.Key()) + decl.CWDDigest = shape.CWDDigest(h.st.Key(), startDirectory(p.CWD, dirs)) // Hostnames are metadata and are stored verbatim, because the report // lists them and forget --host matches them by the name as written. // Nothing else from tool_input is persisted, and shape is still the only @@ -250,20 +256,26 @@ func (h *Handler) Close(sig *Signals, captureErr error) { // startDirectory is the directory a call's command starts in: the payload's // cwd, which is the shell's directory before the line runs, with the targets -// of the line's leading plain `cd DIR &&` steps (shape.LeadingDirectory) -// folded onto it. An absolute target replaces the directory and a relative -// one joins it. Without it the first `cd /repo/web && go test ./...` is keyed -// on /repo and every repeat on /repo/web, although all of them ran in web. A -// relative target with no directory to resolve it against is not known, and -// the directory stays empty until an absolute target names one. +// of the line's leading plain `cd DIR &&` steps (shape.LeadingDirectory's +// own answer, dirs) folded onto it. An absolute target replaces the +// directory and a relative one joins it. Without it the first +// `cd /repo/web && go test ./...` is keyed on /repo and every repeat on +// /repo/web, although all of them ran in web. A relative target with no +// directory to resolve it against is not known, and the directory stays +// empty until an absolute target names one. +// +// dirs, not toolName and toolInput: the caller already has +// shape.DeriveWithDirs's dirs, from the SAME read of the program search +// Derive makes for every other field of the shape. Decoding the command and +// tokenizing it again here, as a toolName/toolInput signature would need to +// call shape.LeadingDirectory on its own, was a second and a third reading +// of a line Hosts then reads once more -- the budget this package derives +// (RES-9) assumes three readings, not at least five. // // Past maxLeadingCd steps the directory is not known, and is returned empty, // which CWDDigest records as unknown: not the payload's cwd, which the cds // moved away from, and not a fold stopped short of where the line ran. -func startDirectory(cwd, toolName string, toolInput json.RawMessage) string { - // The bool says whether the line had a leading run at all; with none, - // dirs is empty and the payload's cwd is the answer, so it adds nothing. - dirs, _ := shape.LeadingDirectory(toolName, toolInput) +func startDirectory(cwd string, dirs []string) string { if len(dirs) > maxLeadingCd { return "" } diff --git a/internal/hook/handle_test.go b/internal/hook/handle_test.go index 88c012f3..4153f4d0 100644 --- a/internal/hook/handle_test.go +++ b/internal/hook/handle_test.go @@ -3,6 +3,8 @@ package hook import ( "encoding/json" "testing" + + "github.com/altrace-dev-role/rashomon/internal/shape" ) // startDirectory folds a line's leading cd targets onto the payload's cwd: @@ -27,7 +29,8 @@ func TestStartDirectory(t *testing.T) { if err != nil { t.Fatal(err) } - if got := startDirectory(tc.cwd, "Bash", in); got != tc.want { + dirs, _ := shape.LeadingDirectory("Bash", in) + if got := startDirectory(tc.cwd, dirs); got != tc.want { t.Errorf("startDirectory(%q, %q) = %q, want %q", tc.cwd, tc.cmd, got, tc.want) } } diff --git a/internal/hook/res10_test.go b/internal/hook/res10_test.go index 78975b77..cfe6b0ed 100644 --- a/internal/hook/res10_test.go +++ b/internal/hook/res10_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + "github.com/altrace-dev-role/rashomon/internal/shape" ) // cdLine is n leading `cd a &&` steps and then go test. @@ -61,11 +63,15 @@ func TestRES10_LeadingCdFoldIsBounded(t *testing.T) { } return raw } + dirsOf := func(cmd string) []string { + dirs, _ := shape.LeadingDirectory("Bash", in(cmd)) + return dirs + } want := "/repo" + strings.Repeat("/a", 64) - if got := startDirectory("/repo", "Bash", in(cdLine(64, " && "))); got != want { + if got := startDirectory("/repo", dirsOf(cdLine(64, " && "))); got != want { t.Errorf("64 steps: start directory %q, want %q", got, want) } - if got := startDirectory("/repo", "Bash", in(cdLine(65, " && "))); got != "" { + if got := startDirectory("/repo", dirsOf(cdLine(65, " && "))); got != "" { t.Errorf("65 steps: start directory %q, want unknown", got) } for _, tc := range []struct { @@ -81,7 +87,15 @@ func TestRES10_LeadingCdFoldIsBounded(t *testing.T) { var m0, m1 runtime.MemStats runtime.GC() runtime.ReadMemStats(&m0) - took := fastest(func() { got = startDirectory("/repo", "Bash", raw) }) + // LeadingDirectory's own tokenizing is inside the timed and measured + // closure, alongside the fold: this bound is on the two of them + // together, the same cost startDirectory's caller pays in production + // (shape.DeriveWithDirs tokenizes once; this measures that reading, + // not a second one of its own). + took := fastest(func() { + dirs, _ := shape.LeadingDirectory("Bash", raw) + got = startDirectory("/repo", dirs) + }) runtime.ReadMemStats(&m1) t.Logf("%s: the fold took %v", tc.name, took) if alloc := m1.TotalAlloc - m0.TotalAlloc; alloc > tc.bound { @@ -105,7 +119,7 @@ func TestRES10_LeadingCdFoldIsBounded(t *testing.T) { {"64 steps, then 200,000 words", cdLine(64, " && ") + long, "/repo" + strings.Repeat("/a", 64)}, {"100 steps, then 200,000 words", cdLine(100, " && ") + long, ""}, } { - if got := startDirectory("/repo", "Bash", in(tc.cmd)); got != tc.want { + if got := startDirectory("/repo", dirsOf(tc.cmd)); got != tc.want { t.Errorf("%s: start directory %q, want %q", tc.name, got, tc.want) } } diff --git a/internal/shape/shape.go b/internal/shape/shape.go index 15aa0ae5..a4aab7f3 100644 --- a/internal/shape/shape.go +++ b/internal/shape/shape.go @@ -123,6 +123,22 @@ func Derive(toolName string, toolInput json.RawMessage, key []byte) Shape { // 85.3.10, "Token or cd bound"), which P-11 serialises; a v4 record does not // carry it. Always false for a tool that is not a shell. func DeriveCut(toolName string, toolInput json.RawMessage, key []byte) (Shape, bool) { + s, cut, _ := deriveCut(toolName, toolInput, key) + return s, cut +} + +// DeriveWithDirs is DeriveCut, plus the leading `cd DIR &&` directories +// (LeadingDirectory's own answer) this already read while deriving the +// shape. shape.go's own reading of the program search (pshaped, perr) is +// exactly LeadingDirectory's starting point, so a caller that wants both -- +// the hook's startDirectory, the only one -- does not tokenize the program +// search a second time just to get what this function already held. +func DeriveWithDirs(toolName string, toolInput json.RawMessage, key []byte) (Shape, bool, []string) { + return deriveCut(toolName, toolInput, key) +} + +// deriveCut is DeriveCut and DeriveWithDirs's shared body. +func deriveCut(toolName string, toolInput json.RawMessage, key []byte) (Shape, bool, []string) { s := Shape{VerbClass: verbForTool(toolName)} // Only a shell tool's input carries a command line. An MCP tool or a @@ -138,7 +154,7 @@ func DeriveCut(toolName string, toolInput json.RawMessage, key []byte) (Shape, b } if !isShell { s.Digest = canonicalDigest(key, toolName, toolInput) - return s, false + return s, false, nil } // A shell tool digests its command line and nothing else. Claude Code @@ -185,7 +201,7 @@ func DeriveCut(toolName string, toolInput json.RawMessage, key []byte) (Shape, b dirs, _ := leadingRun(pshaped, perr) s.StatusMasked, s.RunnerDigest = statusMasked(cmd, key, leadingSteps(dirs)) cut := err == errTokenBound || perr == errTokenBound || len(dirs) > MaxLeadingCd - return s, cut + return s, cut, dirs } // controlByte reports a line holding a control byte, in which no word can be diff --git a/test/mutation/mutants/TB_shape.py b/test/mutation/mutants/TB_shape.py index 21270123..5735891d 100644 --- a/test/mutation/mutants/TB_shape.py +++ b/test/mutation/mutants/TB_shape.py @@ -458,8 +458,8 @@ "\t\tkey := runKey{d.Shape.Digest, \"\"}\n", "TestTestBending") m("TB the declaration carries no cwd digest", "internal/hook/handle.go", - "\t\tCWDDigest: shape.CWDDigest(h.st.Key(), startDirectory(p.CWD, p.ToolName, p.ToolInput)),\n", - "", + "\tdecl.CWDDigest = shape.CWDDigest(h.st.Key(), startDirectory(p.CWD, dirs))\n", + "\t_ = dirs\n", "TestTestBending_ARepeatedRelativeCd|TestSchema3_|TestH13_") m("TB the cwd digest is the plain path", "internal/shape/shape.go", "\treturn digest(key, \"\\x00cwd\", cwd)\n", @@ -605,8 +605,8 @@ "TestSilentFailures_") m("TB a leading cd's target is not the run's directory", "internal/hook/handle.go", - "shape.CWDDigest(h.st.Key(), startDirectory(p.CWD, p.ToolName, p.ToolInput)),", - "shape.CWDDigest(h.st.Key(), p.CWD),", + "\tdecl.CWDDigest = shape.CWDDigest(h.st.Key(), startDirectory(p.CWD, dirs))\n", + "\t_ = dirs\n\tdecl.CWDDigest = shape.CWDDigest(h.st.Key(), p.CWD)\n", "TestTestBending_ALeadingAbsoluteCd") m("TB a relative leading cd keeps the shell's directory", "internal/hook/handle.go", "\t\t\tcwd = filepath.Join(cwd, dir)\n", From ac0a7501ec75f79612bc470ffb20f4ca9acc9182 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Sun, 18 Oct 2026 10:08:04 -0700 Subject: [PATCH 34/50] shape: a whitespace-only remainder past the ssh budget is not a cut sshCommandHosts flagged cut whenever the shared token budget ran out and anything was left in rest, including a remainder that was pure whitespace -- a trailing newline, a blank line with nothing in it. Nothing could have been hiding a host in whitespace, so that's not a cut: the line budget ran out exactly where there was nothing left to read anyway. Switched the check to strings.TrimSpace(rest) != "". Also traced whether a tokenizer stop other than the token bound should ever set lineCut here. errUncertain can't reach sshLineHosts at all: tokenizeShape calls lex with program and docs both false, and every branch in lex that returns errUncertain requires one of those true. An unterminated quote (errUnterminated) can reach it, and staying "not a cut" for that one is right too -- the quote consumes to the end of the line, so there's nothing after it in that line to miss. Left a comment recording the trace. --- internal/shape/hosts.go | 13 ++++++++++- internal/shape/hosts_test.go | 27 ++++++++++++++++++++++ test/mutation/mutants/RES-9_token-bound.py | 5 +++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/internal/shape/hosts.go b/internal/shape/hosts.go index 48ba95e3..647e1ffd 100644 --- a/internal/shape/hosts.go +++ b/internal/shape/hosts.go @@ -199,7 +199,11 @@ func sshCommandHosts(text string) (out []string, cut bool) { hosts, read, lineCut := sshLineHosts(line) out = append(out, hosts...) budget -= max(read, 1) - if lineCut || budget <= 0 && rest != "" { + // strings.TrimSpace(rest), not rest != "": a remainder of pure + // whitespace -- a trailing newline, a blank line -- names nothing + // and could not have held a host no matter how much budget were + // left, so leaving it unread is not a cut. + if lineCut || budget <= 0 && strings.TrimSpace(rest) != "" { cut = true } } @@ -209,6 +213,13 @@ func sshCommandHosts(text string) (out []string, cut bool) { // sshLineHosts is sshCommandHosts over one line, how many tokens it read, and // whether it read the line only to the token bound. Any other tokenizer error // is not a cut: the tokens it completed are the line's. +// +// errUncertain specifically cannot reach here: tokenizeMarked calls +// tokenizeShape, which calls lex with program and docs both false, and every +// branch in lex that returns errUncertain is itself guarded by l.program or +// l.docs. An unterminated quote (errUnterminated) can reach here, and is +// correctly not a cut either -- the quote consumes to the end of the line, +// so there is nothing after it in this line to miss. func sshLineHosts(line string) ([]string, int, bool) { // On a line it cannot finish, lex still returns the tokens it read before // the fault, and a host among them was named all the same. diff --git a/internal/shape/hosts_test.go b/internal/shape/hosts_test.go index d186e172..40146fe0 100644 --- a/internal/shape/hosts_test.go +++ b/internal/shape/hosts_test.go @@ -466,6 +466,33 @@ func TestSSHCommandHosts_LineBudgetIsTheRemainder(t *testing.T) { } } +// TestSSHCommandHosts_BlankRemainderIsNotACut: once the budget is spent, a +// remainder that is pure whitespace -- trailing newlines, a stray blank +// line -- names nothing and could not have held a host no matter how much +// budget were left, so it must not report a cut. sshCommandHosts was +// checking rest != "" rather than whether rest held anything but +// whitespace, so a command that happened to end in a blank line after +// exhausting the budget on its real content reported cut=true for content +// that was never actually missed. +func TestSSHCommandHosts_BlankRemainderIsNotACut(t *testing.T) { + filler := strings.Repeat("x ", maxTokens) + _, cut := sshCommandHosts(filler + "\n ") + if cut { + t.Error("cut = true, want false: the remainder after the budget ran out was pure whitespace, nothing was actually left unread") + } +} + +// TestSSHCommandHosts_RealRemainderIsStillACut pins the case the fix above +// must not break: a non-blank remainder left unread when the budget runs out +// is still a cut. +func TestSSHCommandHosts_RealRemainderIsStillACut(t *testing.T) { + filler := strings.Repeat("x ", maxTokens) + _, cut := sshCommandHosts(filler + "\nssh real-host.example.com") + if !cut { + t.Error("cut = false, want true: a real line was left unread when the budget ran out") + } +} + // TestSSHDestinationRefusesACredential is CWE-522 on the positional path. // // A destination carrying a password is refused, not stripped. Splitting at diff --git a/test/mutation/mutants/RES-9_token-bound.py b/test/mutation/mutants/RES-9_token-bound.py index 159cfe5c..8889e59f 100644 --- a/test/mutation/mutants/RES-9_token-bound.py +++ b/test/mutation/mutants/RES-9_token-bound.py @@ -29,8 +29,11 @@ "\treturn out, len(toks), err == errTokenBound\n", "\treturn out, len(toks), err == errUncertain\n", "TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut") m("RES-9 lines left past the ssh budget are not reported as cut", "internal/shape/hosts.go", - "\t\tif lineCut || budget <= 0 && rest != \"\" {\n", "\t\tif lineCut {\n", + "\t\tif lineCut || budget <= 0 && strings.TrimSpace(rest) != \"\" {\n", "\t\tif lineCut {\n", "TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut") +m("RES-9 a blank-whitespace remainder past the ssh budget is still reported as cut", "internal/shape/hosts.go", + "\t\tif lineCut || budget <= 0 && strings.TrimSpace(rest) != \"\" {\n", "\t\tif lineCut || budget <= 0 && rest != \"\" {\n", + "TestSSHCommandHosts_BlankRemainderIsNotACut") m("RES-9 HostsCut drops the ssh reader's cut", "internal/shape/hosts.go", "\t\tnamed, cut = sshCommandHosts(text)\n", "\t\tnamed, _ = sshCommandHosts(text)\n", "TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut") From 8365924263bd068b6007f42ef90960774e1e98e2 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Sun, 18 Oct 2026 10:12:17 -0700 Subject: [PATCH 35/50] report: baseline errors are not all the same error buildNovelty rendered every baseline.UpdateOrigin failure as "the project baseline could not be read or written" -- a lock held by a concurrent render, a session naming more hosts than a baseline can hold, and a corrupt file all read identically, so there was no way to tell "try again" from "something is actually wrong with this file." baseline.ErrTooManyHosts and store.ErrLockTimeout are already named errors for exactly this reason. Pulled the mapping into baselineErrorReason, which tells those two apart and keeps the rest on the existing generic message, deliberately not the error text -- it can carry the baseline's path. --- internal/report/destinations.go | 26 +++++++-- internal/report/destinations_test.go | 54 +++++++++++++++++++ .../mutants/NOV_baseline-error-messages.py | 14 +++++ 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 test/mutation/mutants/NOV_baseline-error-messages.py diff --git a/internal/report/destinations.go b/internal/report/destinations.go index 424a0bdd..3d4f63bf 100644 --- a/internal/report/destinations.go +++ b/internal/report/destinations.go @@ -1,6 +1,7 @@ package report import ( + "errors" "sort" "strings" "time" @@ -245,9 +246,7 @@ func buildNovelty(run *store.Run, storeRoot string, wireHosts, sshHosts []string res := baseline.UpdateOrigin(storeRoot, baseline.Key(cwd), run.SessionID(), time.UnixMilli(runStartMS(run)), hosts, sshSet) if res.Err != nil { - // Deliberately not the error text: it carries the baseline path, and a - // path carries someone's directory names. - n.Reason = "the project baseline could not be read or written" + n.Reason = baselineErrorReason(res.Err) return n } n.Available = true @@ -260,6 +259,27 @@ func buildNovelty(run *store.Run, storeRoot string, wireHosts, sshHosts []string return n } +// baselineErrorReason tells apart the two named baseline errors a caller can +// act on from the rest, which it describes the same way it always has. +// +// A render and `forget --host` both take the baseline's lock, and a reader +// who sees the same sentence for that as for a corrupt file has no way to +// tell "try again" from "look at this." baseline.ErrTooManyHosts and +// store.ErrLockTimeout are named for exactly this reason; everything else -- +// a corrupt file, an unreadable one, a version this build does not know -- +// stays the generic message, deliberately not the error text: it carries +// the baseline path, and a path carries someone's directory names. +func baselineErrorReason(err error) string { + switch { + case errors.Is(err, baseline.ErrTooManyHosts): + return "this session named more hosts than a project baseline can hold, so novelty could not be updated this time" + case errors.Is(err, store.ErrLockTimeout): + return "the project baseline was locked by another render, so novelty could not be checked this time" + default: + return "the project baseline could not be read or written" + } +} + // runCWD is the working directory the run recorded, preferring the start-phase // record because that is the one written before any tool call could change it. func runCWD(run *store.Run) string { diff --git a/internal/report/destinations_test.go b/internal/report/destinations_test.go index b0d8c893..e32123b9 100644 --- a/internal/report/destinations_test.go +++ b/internal/report/destinations_test.go @@ -2,10 +2,13 @@ package report import ( "bytes" + "errors" + "fmt" "slices" "strings" "testing" + "github.com/altrace-dev-role/rashomon/internal/baseline" "github.com/altrace-dev-role/rashomon/internal/nono" "github.com/altrace-dev-role/rashomon/internal/shape" "github.com/altrace-dev-role/rashomon/internal/store" @@ -273,6 +276,57 @@ func TestNovelty_UnreadableStoreSaysWhyRatherThanVanishing(t *testing.T) { } } +// TestBaselineErrorReason_DistinctCausesDistinctMessages: a locked baseline, +// a session naming more hosts than a baseline can hold, and a corrupt or +// unreadable file are three different situations, and a reader who sees "the +// project baseline could not be read or written" for all three cannot tell a +// transient contention from a reason to go look at the file. The first two +// have their own named errors (baseline.ErrTooManyHosts, store.ErrLockTimeout) +// precisely so a caller can tell them apart; only the generic message is +// deliberately silent about which file. +func TestBaselineErrorReason_DistinctCausesDistinctMessages(t *testing.T) { + tooMany := baselineErrorReason(baseline.ErrTooManyHosts) + locked := baselineErrorReason(store.ErrLockTimeout) + generic := baselineErrorReason(errors.New("baseline: unrecognised file version")) + for _, pair := range [][2]string{{tooMany, locked}, {tooMany, generic}, {locked, generic}} { + if pair[0] == pair[1] { + t.Errorf("two distinct baseline errors produced the same reason %q", pair[0]) + } + } + if generic == "" || strings.Contains(generic, "unrecognised file version") { + t.Errorf("generic reason %q: must say something, and must not repeat the error text, "+ + "which can carry the baseline's path", generic) + } +} + +// TestNovelty_TooManyHostsSaysSoNotTheGenericMessage: the integration path, +// not just the mapping function above -- a session that names more hosts +// than a baseline holds must reach the reader as something more specific +// than the generic "could not be read or written", which a lock timeout and +// a corrupt file also produce. +func TestNovelty_TooManyHostsSaysSoNotTheGenericMessage(t *testing.T) { + hosts := make([]string, 5000) + for i := range hosts { + hosts[i] = fmt.Sprintf("h%d.example", i) + } + run := &store.Run{ + Declarations: []store.Declaration{{ + ToolUseID: "t1", ToolName: "Bash", SessionID: "sess-1", Hosts: hosts, + }}, + Coverage: []store.Coverage{{ + Phase: store.PhaseStart, RecordedAtMS: t0ms, CWD: t.TempDir(), SessionID: "sess-1", + }}, + } + d := buildDestinations(run, t.TempDir(), nil) + if d.Novelty.Available { + t.Fatal("novelty reports itself available for a session past the baseline's host cap") + } + if d.Novelty.Reason == "the project baseline could not be read or written" { + t.Errorf("reason %q: a session naming too many hosts reads identically to a locked "+ + "or corrupt baseline", d.Novelty.Reason) + } +} + // TestClientPlane_EveryViewAppliesTheOneRule: whether a host is the client's // own traffic is asked by two readers -- the novelty candidates and the // sandbox section -- and one of them used to restate the rule instead of diff --git a/test/mutation/mutants/NOV_baseline-error-messages.py b/test/mutation/mutants/NOV_baseline-error-messages.py new file mode 100644 index 00000000..a87bbf16 --- /dev/null +++ b/test/mutation/mutants/NOV_baseline-error-messages.py @@ -0,0 +1,14 @@ +# baseline.ErrTooManyHosts and store.ErrLockTimeout are named so a caller can +# act on them differently from a corrupt or unreadable file, which stays the +# generic message on purpose (the error text carries the baseline's path). +# Collapsing either named case back into the generic one, or making the +# generic one echo the error text, is the regression. +m("a too-many-hosts baseline error reads as the generic message", "internal/report/destinations.go", + "\tcase errors.Is(err, baseline.ErrTooManyHosts):\n\t\treturn \"this session named more hosts than a project baseline can hold, so novelty could not be updated this time\"\n", + "", "TestBaselineErrorReason_DistinctCausesDistinctMessages|TestNovelty_TooManyHostsSaysSoNotTheGenericMessage") +m("a locked baseline error reads as the generic message", "internal/report/destinations.go", + "\tcase errors.Is(err, store.ErrLockTimeout):\n\t\treturn \"the project baseline was locked by another render, so novelty could not be checked this time\"\n", + "", "TestBaselineErrorReason_DistinctCausesDistinctMessages") +m("the generic baseline error repeats the error text", "internal/report/destinations.go", + "\treturn \"the project baseline could not be read or written\"\n", + "\treturn err.Error()\n", "TestBaselineErrorReason_DistinctCausesDistinctMessages") From cb8557f7a56b34f1e25f85839939250fe907e62a Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Sun, 18 Oct 2026 10:17:48 -0700 Subject: [PATCH 36/50] baseline: bound a session id before an entry stores it maxEntryBytes's comment assumed a session id of up to 256 bytes, citing RECORD's id cap -- except nothing enforces that cap anywhere. A session id is free text from the hook payload, bounded only by hookio's 8 MiB whole- payload limit, and it is stored once per host a session names (entry.FirstSeenSession) plus once per file (File.EstablishedBySession). An abnormally long one could make a single render's marshaled file exceed maxBaselineBytes; save already refuses to write that, which avoids corrupting the file on disk, but it does nothing to stop the next session with a normal-sized id from losing its own render the same way, over and over, for as long as that one oversized id keeps getting re-folded in. Added clampSessionID and call it once, at the top of foldOrigin, before either field is set from the id. Truncating mid-rune is fine: a session id is an opaque comparison key here, never displayed, and the same input truncates to the same bytes every render, so a later render of the same oversized id still matches what an earlier one stored. --- internal/baseline/baseline_test.go | 41 +++++++++++++++++++ internal/baseline/fold.go | 33 ++++++++++++++- .../mutants/RUBRIC_lld-11_session-id-bound.py | 6 +++ 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 test/mutation/mutants/RUBRIC_lld-11_session-id-bound.py diff --git a/internal/baseline/baseline_test.go b/internal/baseline/baseline_test.go index 86f6d62c..d7262e92 100644 --- a/internal/baseline/baseline_test.go +++ b/internal/baseline/baseline_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" "time" ) @@ -412,3 +413,43 @@ func TestUpdate_TheEstablisherCorrectsTowardTheEarlierSession(t *testing.T) { againLater.Novel) } } + +// TestFold_SessionIDIsBoundedBeforeStorage: a session id of unbounded length +// is not hostname-shaped and nothing here rejects it the way a host is +// rejected, so maxEntryBytes's own math -- a 253-byte name, a session id of +// up to 256 bytes, two stamps, keys and indentation, 1,024 with room for +// escaping -- was an assumption nothing enforced. An abnormally long session +// id, stored once per host it names, could make a single render's file +// larger than maxBaselineBytes; save refuses to write that file, but the +// baseline on disk from before the render is untouched either way, which +// reads as fine right up until a project's baseline genuinely needs that +// capacity for real hosts and finds it already spent on one session's id. +// Bounding the id at the point it enters a stored entry makes the comment's +// own assumption true by construction instead of by convention. +func TestFold_SessionIDIsBoundedBeforeStorage(t *testing.T) { + huge := strings.Repeat("s", 10_000) + f, res, changed := Fold(File{}, false, huge, t0, []string{"a.example"}) + if !changed { + t.Fatal("premise: establishing a baseline is a change") + } + if res.Err != nil { + t.Fatalf("Fold with an oversized session id: %v", res.Err) + } + if n := len(f.EstablishedBySession); n > maxSessionIDBytes { + t.Errorf("EstablishedBySession is %d bytes, want at most %d", n, maxSessionIDBytes) + } + e, ok := f.Hosts["a.example"] + if !ok { + t.Fatal("a.example was not recorded") + } + if n := len(e.FirstSeenSession); n > maxSessionIDBytes { + t.Errorf("FirstSeenSession is %d bytes, want at most %d", n, maxSessionIDBytes) + } + // And a second render of the SAME (still huge) id must still recognise + // it as the same session: bounding it once, consistently, rather than + // comparing the bounded form to the unbounded one. + _, res2, _ := Fold(f, true, huge, t0, []string{"a.example"}) + if !res2.Established { + t.Error("a second render of the same oversized session id no longer reads as the establisher") + } +} diff --git a/internal/baseline/fold.go b/internal/baseline/fold.go index 8abb5ffb..881469ea 100644 --- a/internal/baseline/fold.go +++ b/internal/baseline/fold.go @@ -24,9 +24,35 @@ const lockName = ".lock" // whose return is least misleading to call new. const maxBaselineHosts = 4096 +// maxSessionIDBytes bounds a session id as foldOrigin stores it, in either +// of the two fields that hold one (entry.FirstSeenSession, +// File.EstablishedBySession). Nothing upstream caps a session id's length +// the way a hostname is capped (host.Shaped) -- it is free text from the +// hook payload, bounded only by hookio.MaxPayloadBytes (8 MiB) -- so +// maxEntryBytes's own math below used to ASSUME a 256-byte id without this +// package enforcing it anywhere. clampSessionID makes that assumption true +// by construction: foldOrigin bounds sessionID once, before either field is +// set from it, rather than each of the two call sites re-deriving the same +// bound. +const maxSessionIDBytes = 256 + +// clampSessionID bounds a session id to maxSessionIDBytes, which may cut a +// multi-byte UTF-8 rune in half; that is fine here, because a session id is +// an opaque comparison key to this package, never displayed, and +// encoding/json replaces an invalid trailing sequence with U+FFFD on marshal +// rather than failing to encode. The same input truncates to the same +// bytes every time, so a later render of the same (still oversized) session +// id still compares equal to what an earlier render stored. +func clampSessionID(id string) string { + if len(id) > maxSessionIDBytes { + return id[:maxSessionIDBytes] + } + return id +} + // maxEntryBytes bounds one host's entry as save writes it: a 253-byte name, -// a session id of up to 256 bytes (RECORD's id cap), two 13-digit stamps, -// and the keys and indentation, about 140 bytes -- 1,024 with room for JSON +// a session id of up to maxSessionIDBytes, two 13-digit stamps, and the +// keys and indentation, about 140 bytes -- 1,024 with room for JSON // escaping. maxBaselineBytes is the file at the cap of such entries, plus // 64 KiB for the header and its project key, a path. A file larger than that // is not one this package wrote, and is read as corrupt rather than read @@ -69,6 +95,9 @@ func FoldOrigin(f File, existed bool, sessionID string, sessionStart time.Time, } func foldOrigin(f File, existed bool, sessionID string, sessionStart time.Time, hosts []string, sshHosts map[string]bool) (File, Result, bool) { + // Bounded once, here, before establish and the per-host loop below each + // store it: see clampSessionID. + sessionID = clampSessionID(sessionID) stamp := sessionStart.UTC().UnixMilli() out := f out.Hosts = maps.Clone(f.Hosts) diff --git a/test/mutation/mutants/RUBRIC_lld-11_session-id-bound.py b/test/mutation/mutants/RUBRIC_lld-11_session-id-bound.py new file mode 100644 index 00000000..3b3d3070 --- /dev/null +++ b/test/mutation/mutants/RUBRIC_lld-11_session-id-bound.py @@ -0,0 +1,6 @@ +# maxEntryBytes's own math assumes a session id of at most maxSessionIDBytes; +# nothing upstream enforces that, so foldOrigin must. Not clamping it is the +# regression. +m("a session id is not bounded before an entry stores it", "internal/baseline/fold.go", + "\tsessionID = clampSessionID(sessionID)\n", "", + "TestFold_SessionIDIsBoundedBeforeStorage") From ede43b9247024135b5053bd57e23c501866954d0 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Sun, 18 Oct 2026 10:27:05 -0700 Subject: [PATCH 37/50] baseline, cmd/rashomon: forget --host lists baselines under the same lock it clears them with clearBaselines listed the baseline directory, then called ForgetFile once per entry, which takes and releases the directory's lock itself for each file. A project baseline a concurrent render establishes between the listing and the walk reaching it is not in the list no matter how fast the walk runs afterward -- Update does not wait for a scan that already decided what to look at. Moved the whole operation into baseline.ForgetAll, which takes the directory lock once and lists under it. It can't call ForgetFile for the per-file work either, since that takes the same lock again and flock() does not treat a second open file description from the same process as already held -- it blocks behind the first. ForgetAll calls forgetLocked directly instead, reusing the lock it already took. clearBaselines is now a one-line wrapper around it. --- cmd/rashomon/main.go | 29 +---- internal/baseline/baseline.go | 55 ++++++++ internal/baseline/forget_all_test.go | 122 ++++++++++++++++++ ...RUBRIC_lld-11_forget-all-locked-listing.py | 28 ++++ .../mutation/mutants/TRU-58_baseline-basis.py | 2 +- 5 files changed, 211 insertions(+), 25 deletions(-) create mode 100644 internal/baseline/forget_all_test.go create mode 100644 test/mutation/mutants/RUBRIC_lld-11_forget-all-locked-listing.py diff --git a/cmd/rashomon/main.go b/cmd/rashomon/main.go index 14dfa519..2bae87f0 100644 --- a/cmd/rashomon/main.go +++ b/cmd/rashomon/main.go @@ -12,7 +12,6 @@ import ( "errors" "fmt" "io" - "io/fs" "os" "path/filepath" "strconv" @@ -1990,30 +1989,12 @@ func countOf(n int, noun string) string { // reports whether the store holds a baseline directory at all. // // A file it cannot clear does not stop it: the host still leaves every other -// project's baseline, and the failures are returned together. +// project's baseline, and the failures are returned together. The listing +// and the per-file work are baseline.ForgetAll's, under its one lock -- see +// its own doc for why this cannot list the directory itself and call +// baseline.ForgetFile per file the way it once did. func clearBaselines(root, raw string) (int, bool, error) { - dir := filepath.Join(root, "baseline") - entries, err := os.ReadDir(dir) - if errors.Is(err, fs.ErrNotExist) { - return 0, false, nil - } - if err != nil { - return 0, true, err - } - var cleared int - var failed []error - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { - continue - } - n, err := baseline.ForgetFile(filepath.Join(dir, e.Name()), raw) - if err != nil { - failed = append(failed, err) - continue - } - cleared += n - } - return cleared, true, errors.Join(failed...) + return baseline.ForgetAll(filepath.Join(root, "baseline"), raw) } // storeExistsAt asks whether a root holds a store without opening one. diff --git a/internal/baseline/baseline.go b/internal/baseline/baseline.go index 0f3056be..dc887a57 100644 --- a/internal/baseline/baseline.go +++ b/internal/baseline/baseline.go @@ -433,6 +433,61 @@ func ForgetFile(path, raw string) (int, error) { return forgetLocked(path, raw, name) } +// ForgetAll removes a host from every project baseline under dir (the +// store's baseline directory), the way `forget --host` has to -- it is not +// told which project the caller meant. It returns how many files held the +// host, and whether dir holds a baseline directory at all. +// +// Listing dir and forgetting each file happen under ONE lock, held for the +// whole walk, not one lock per file as a caller using ForgetFile in a loop +// would take. A project baseline that a concurrent render ESTABLISHES +// between the listing and this walk reaching it is exactly the file a +// per-file lock cannot see: nothing stops Update from creating it in the gap +// between two separately-locked steps, and a file that did not exist at +// listing time is not in the list no matter how fast the forget runs. This +// function cannot call ForgetFile for the per-file work either -- it already +// holds the one lock the directory has, and a second store.LockPath call on +// the same path from this process is a second open file description, which +// flock() does not treat as already held; it blocks behind the first rather +// than succeeding. So this calls forgetLocked directly, under the lock this +// function itself took. +func ForgetAll(dir, raw string) (cleared int, hadBaselines bool, err error) { + name, ok := host.Canonical(raw) + if !ok { + return 0, false, store.ErrNotAHostname + } + release, lockErr := store.LockPath(filepath.Join(dir, lockName)) + if errors.Is(lockErr, fs.ErrNotExist) { + // No baseline directory: nothing to clear. + return 0, false, nil + } + if lockErr != nil { + return 0, true, lockErr + } + defer release() + + entries, err := os.ReadDir(dir) + if errors.Is(err, fs.ErrNotExist) { + return 0, false, nil + } + if err != nil { + return 0, true, err + } + var failed []error + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + n, err := forgetLocked(filepath.Join(dir, e.Name()), raw, name) + if err != nil { + failed = append(failed, err) + continue + } + cleared += n + } + return cleared, true, errors.Join(failed...) +} + // forgetLocked is ForgetFile under the lock, for the host raw whose canonical // spelling is name. func forgetLocked(path, raw, name string) (int, error) { diff --git a/internal/baseline/forget_all_test.go b/internal/baseline/forget_all_test.go new file mode 100644 index 00000000..57f92262 --- /dev/null +++ b/internal/baseline/forget_all_test.go @@ -0,0 +1,122 @@ +package baseline + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/altrace-dev-role/rashomon/internal/safe" + "github.com/altrace-dev-role/rashomon/internal/store" +) + +// TestForgetAll_ListingIsUnderTheLock: a caller that lists a baseline +// directory and then forgets each file under a lock taken per file, as +// clearBaselines once did, can miss a project baseline a concurrent render +// establishes in the gap between the listing and this walk reaching it -- +// Update does not wait for a scan that already finished listing, so a file +// it creates after that point is simply not in the list, no matter how +// quickly the forget runs afterward. Taking the lock once, before the +// listing, makes that gap impossible: anyone else who wants the lock, +// including a render that would have created a new file, waits for +// ForgetAll to finish first. Pinned here by the structural guarantee rather +// than by racing goroutines: ForgetAll cannot even start listing while the +// lock is held elsewhere. +func TestForgetAll_ListingIsUnderTheLock(t *testing.T) { + root := t.TempDir() + if r := Update(root, "/proj", "s1", t0, []string{"a.example"}); r.Err != nil { + t.Fatalf("establishing a baseline to list: %v", r.Err) + } + dir := filepath.Join(root, "baseline") + release, err := store.LockPath(filepath.Join(dir, lockName)) + if err != nil { + t.Fatalf("taking the baseline lock ahead of ForgetAll: %v", err) + } + defer release() + + if _, _, err := ForgetAll(dir, "a.example"); !errors.Is(err, store.ErrLockTimeout) { + t.Errorf("ForgetAll while the directory lock is held elsewhere: %v, want "+ + "ErrLockTimeout -- if it could list the directory without the lock, "+ + "a baseline established in the gap before it reached the per-file "+ + "work would never be visited", err) + } +} + +// TestForgetAll_SeesAFileCreatedWhileItWasBlockedOnTheLock makes the race the +// previous test proves impossible concrete and deterministic: a project +// baseline that comes into existence WHILE ForgetAll is waiting for the +// lock must still be visited once it gets in. A version that lists the +// directory before taking the lock would have already decided the file list +// by the time the lock opens up, and would walk past this file never having +// known it was there -- not because it raced and lost, but because listing +// before locking means there is nothing to race: the file simply postdates +// the decision of what to look at. +func TestForgetAll_SeesAFileCreatedWhileItWasBlockedOnTheLock(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "baseline") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + release, err := store.LockPath(filepath.Join(dir, lockName)) + if err != nil { + t.Fatalf("taking the baseline lock ahead of ForgetAll: %v", err) + } + + done := safe.Go(func() { + cleared, _, err := ForgetAll(dir, "a.example") + if err != nil { + t.Errorf("ForgetAll: %v", err) + } + if cleared != 1 { + t.Errorf("cleared = %d, want 1: a baseline created while ForgetAll waited "+ + "for the lock was not visited once it got in", cleared) + } + }, func(err error) { t.Errorf("ForgetAll panicked: %v", err) }) + // Long enough that ForgetAll has reached whatever it does ahead of + // blocking on the lock this goroutine holds -- an unlocked os.ReadDir, + // in the version this pins against, finishes in microseconds. + time.Sleep(50 * time.Millisecond) + writeLegacyBaseline(t, root, "/new", map[string]entry{ + "a.example": {FirstSeenSession: "s1", FirstSeenAtUnixMS: 1}, + }) + release() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("ForgetAll did not finish after the lock it was waiting for was released") + } +} + +// TestForgetAll_ClearsEveryProjectAtomically pins the behaviour the refactor +// must not break: multiple projects, one of them not naming the host at all, +// all cleared by one call. +func TestForgetAll_ClearsEveryProjectAtomically(t *testing.T) { + root := t.TempDir() + Update(root, "/p1", "s1", t0, []string{"a.example"}) + Update(root, "/p2", "s2", t0, []string{"a.example", "b.example"}) + Update(root, "/p3", "s3", t0, []string{"b.example"}) + + cleared, had, err := ForgetAll(filepath.Join(root, "baseline"), "a.example") + if err != nil { + t.Fatalf("ForgetAll: %v", err) + } + if !had { + t.Error("had = false, want true: a baseline directory exists") + } + if cleared != 2 { + t.Errorf("cleared = %d, want 2: a.example was in p1 and p2, not p3", cleared) + } +} + +// TestForgetAll_NoBaselineDirectoryIsNotAnError pins the behaviour +// clearBaselines relied on: a store with no baseline directory yet reports +// hadBaselines=false and no error, not ErrNotAHostname's sibling or a lock +// failure on a directory that was never created. +func TestForgetAll_NoBaselineDirectoryIsNotAnError(t *testing.T) { + cleared, had, err := ForgetAll(filepath.Join(t.TempDir(), "baseline"), "a.example") + if err != nil || had || cleared != 0 { + t.Errorf("ForgetAll on a store with no baseline directory: cleared=%d had=%v err=%v, want 0, false, nil", + cleared, had, err) + } +} diff --git a/test/mutation/mutants/RUBRIC_lld-11_forget-all-locked-listing.py b/test/mutation/mutants/RUBRIC_lld-11_forget-all-locked-listing.py new file mode 100644 index 00000000..acae804e --- /dev/null +++ b/test/mutation/mutants/RUBRIC_lld-11_forget-all-locked-listing.py @@ -0,0 +1,28 @@ +# ForgetAll's whole point is listing the baseline directory under the SAME +# lock the per-file work holds, so a project baseline a concurrent render +# establishes between the listing and the walk reaching it cannot exist -- +# the render would be waiting on the same lock. Listing before the lock is +# taken reopens exactly that gap. +m("ForgetAll lists the baseline directory before taking the lock", "internal/baseline/baseline.go", + "\trelease, lockErr := store.LockPath(filepath.Join(dir, lockName))\n" + "\tif errors.Is(lockErr, fs.ErrNotExist) {\n" + "\t\t// No baseline directory: nothing to clear.\n" + "\t\treturn 0, false, nil\n" + "\t}\n" + "\tif lockErr != nil {\n" + "\t\treturn 0, true, lockErr\n" + "\t}\n" + "\tdefer release()\n" + "\n" + "\tentries, err := os.ReadDir(dir)\n", + "\tentries, listErr := os.ReadDir(dir)\n" + "\trelease, lockErr := store.LockPath(filepath.Join(dir, lockName))\n" + "\tif errors.Is(lockErr, fs.ErrNotExist) {\n" + "\t\treturn 0, false, nil\n" + "\t}\n" + "\tif lockErr != nil {\n" + "\t\treturn 0, true, lockErr\n" + "\t}\n" + "\tdefer release()\n" + "\terr = listErr\n", + "TestForgetAll_SeesAFileCreatedWhileItWasBlockedOnTheLock") diff --git a/test/mutation/mutants/TRU-58_baseline-basis.py b/test/mutation/mutants/TRU-58_baseline-basis.py index 59d0abbd..43fd993a 100644 --- a/test/mutation/mutants/TRU-58_baseline-basis.py +++ b/test/mutation/mutants/TRU-58_baseline-basis.py @@ -36,7 +36,7 @@ m("TRU-58 forget reads an unreadable baseline as cleared", "internal/baseline/baseline.go", "\t\treturn 0, err\n\t}\n\tf, err := decode(body)", "\t\treturn 0, nil\n\t}\n\tf, err := decode(body)", "TestTRU58_ForgetFailsOnABaselineItCannotRead") -m("TRU-58 forget stops at the first baseline it cannot clear", "cmd/rashomon/main.go", +m("TRU-58 forget stops at the first baseline it cannot clear", "internal/baseline/baseline.go", "\t\t\tfailed = append(failed, err)\n\t\t\tcontinue", "\t\t\treturn cleared, true, err", "TestTRU58_ForgetClearsEveryBaselineItCanWhenOneFails") m("TRU-58 forget matches the host inside another name", "internal/baseline/baseline.go", From 3c82fe89cc06e0d7655dc5dcc9b321f6a07d3239 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Wed, 21 Oct 2026 22:36:40 -0700 Subject: [PATCH 38/50] shape: lex an ssh line only to the token budget the command has left sshCommandHosts capped a line's bytes at budget*bytesPerToken, but bytesPerToken is heap bytes per token and a token can be two input bytes, so `ssh a;` repeated after a 500-token remainder still lexed a full maxTokens: about 2x the bound over the command. The line's lexer is now held to the remaining tokens (lexTo, tokenizeMarkedTo), and its token slice is presized for no more than that. RES-9's "last line the budget reads" case read maxTokens+1 tokens and expected no cut; it passed only because of the overrun. The boundary is now tested at N and N+1 tokens. TestRES9_SSHLineIsLexedOnlyToTheBudgetLeft holds a line read with 500 tokens left to that remainder's cost; three breaks are registered in RES-9_token-bound.py and three anchors follow the renamed bound. --- internal/shape/hosts.go | 28 +++++++----------- internal/shape/hosts_test.go | 3 +- internal/shape/res9_cut_test.go | 33 ++++++++++++++++++++-- internal/shape/tokenize.go | 30 ++++++++++++++++---- test/mutation/mutants/RES-9_token-bound.py | 22 +++++++++++++-- 5 files changed, 85 insertions(+), 31 deletions(-) diff --git a/internal/shape/hosts.go b/internal/shape/hosts.go index 647e1ffd..a66992bb 100644 --- a/internal/shape/hosts.go +++ b/internal/shape/hosts.go @@ -183,20 +183,12 @@ func sshCommandHosts(text string) (out []string, cut bool) { for rest, budget := text, maxTokens; rest != "" && budget > 0; { var line string line, rest, _ = strings.Cut(rest, "\n") - // sshLineHosts's own lexer knows nothing of the budget shared across - // lines -- it always reads up to the full maxTokens -- so a line - // reached with almost none of the shared budget left could still - // cost a whole line's worth of tokenizing: about 2x the stated bound - // in total, measured. bytesPerToken is already how a token is priced - // in bytes (RES-9's own derivation); capping this line's bytes at - // the budget actually left caps the tokens it can cost the same way, - // at the price of a cut landing mid-byte on the rare line that - // crosses this window, no worse than the token bound already does. - if window := budget * bytesPerToken; len(line) > window { - line = line[:window] - cut = true - } - hosts, read, lineCut := sshLineHosts(line) + // The line is lexed to the budget left and no further. Lexed to the + // full maxTokens, a line reached with a few hundred tokens left cost + // a whole line's worth again: about 2x the bound over the command. + // Its bytes are no proxy for its tokens -- `a;` is two -- so the cap + // is on the tokens themselves. + hosts, read, lineCut := sshLineHosts(line, budget) out = append(out, hosts...) budget -= max(read, 1) // strings.TrimSpace(rest), not rest != "": a remainder of pure @@ -210,8 +202,8 @@ func sshCommandHosts(text string) (out []string, cut bool) { return out, cut } -// sshLineHosts is sshCommandHosts over one line, how many tokens it read, and -// whether it read the line only to the token bound. Any other tokenizer error +// sshLineHosts is sshCommandHosts over one line read to at most limit tokens, +// how many tokens it read, and whether it read the line only to that bound. Any other tokenizer error // is not a cut: the tokens it completed are the line's. // // errUncertain specifically cannot reach here: tokenizeMarked calls @@ -220,10 +212,10 @@ func sshCommandHosts(text string) (out []string, cut bool) { // l.docs. An unterminated quote (errUnterminated) can reach here, and is // correctly not a cut either -- the quote consumes to the end of the line, // so there is nothing after it in this line to miss. -func sshLineHosts(line string) ([]string, int, bool) { +func sshLineHosts(line string, limit int) ([]string, int, bool) { // On a line it cannot finish, lex still returns the tokens it read before // the fault, and a host among them was named all the same. - toks, meta, err := tokenizeMarked(line) + toks, meta, err := tokenizeMarkedTo(line, limit) var out []string atCommand := true diff --git a/internal/shape/hosts_test.go b/internal/shape/hosts_test.go index 40146fe0..f06f9996 100644 --- a/internal/shape/hosts_test.go +++ b/internal/shape/hosts_test.go @@ -457,8 +457,7 @@ func TestSSHCommandHosts_LineBudgetIsTheRemainder(t *testing.T) { // regardless of the 2 tokens of budget left, which costs about as much // again as the filler line itself -- roughly doubling the total, the "2x // the bound" the trailing line's own lexer has no notion of sharing. - // 256 KiB is generous for reading only the ~536 bytes (2 tokens' - // worth, bytesPerToken) the remaining budget actually allows. + // 256 KiB is generous for the 2 tokens the remaining budget allows. if delta := int64(withTrailer) - int64(fillerOnly); delta > 256<<10 { t.Errorf("a trailing 6 MB line with 2 tokens of budget left added %d KiB on top of the "+ "filler line's own %d KiB; want at most 256 KiB, not a full line's worth of lexing", diff --git a/internal/shape/res9_cut_test.go b/internal/shape/res9_cut_test.go index 0cb78402..edb3f636 100644 --- a/internal/shape/res9_cut_test.go +++ b/internal/shape/res9_cut_test.go @@ -46,8 +46,9 @@ func TestRES9_DeriveSaysALineWasCut(t *testing.T) { // are read over the token bound for the whole command (RES-9), so one named // past it is not read, and before this the list came back short with nothing // to say so. It is now reported as cut: past the bound within one line, and -// past it across lines, where each line costs at least one. The line count -// is tested at N and N+1. cut is about the ssh list: a URL host past the +// past it across lines, where each line costs at least one. The command's +// tokens are tested at N and N+1: a last line that would carry the command +// one token past the bound is read only to it, and its host is past it. cut is about the ssh list: a URL host past the // bound is still in the wire list, since the URL scan reads the whole text, // while the same line is cut for the ssh reader, which stopped at the bound. func TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut(t *testing.T) { @@ -60,7 +61,8 @@ func TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut(t *testing.T) { }{ {"named and read", "ssh " + h, true, false}, {"past the bound in one line", words(maxTokens) + "; ssh " + h, false, true}, - {"the last line the budget reads", strings.Repeat("true\n", maxTokens-1) + "ssh " + h, true, false}, + {"the last line the budget reads", strings.Repeat("true\n", maxTokens-2) + "ssh " + h, true, false}, + {"a last line one token past it", strings.Repeat("true\n", maxTokens-1) + "ssh " + h, false, true}, {"the first line it does not", strings.Repeat("true\n", maxTokens) + "ssh " + h, false, true}, {"a long last line cut at the bound", "ssh " + h + "\n" + words(maxTokens), true, true}, } { @@ -80,3 +82,28 @@ func TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut(t *testing.T) { t.Errorf("WebFetch: cut") } } + +// TestRES9_SSHLineIsLexedOnlyToTheBudgetLeft: the line read when a +// few hundred tokens of the shared budget are left is lexed to those tokens +// and no further. Capping the line's bytes at budget*bytesPerToken did not +// do that: bytesPerToken is the heap a token costs, and a token can be two +// input bytes, so `ssh a;` repeated lexed a full maxTokens past a 500-token +// remainder, about 2x the bound over the command. The delta is held to the +// remainder priced at bytesPerToken, which is Derive's three readings of a +// token and so generous for this one. +func TestRES9_SSHLineIsLexedOnlyToTheBudgetLeft(t *testing.T) { + const left = 500 + filler := strings.Repeat("x ", maxTokens-left) + text := filler + "\n" + strings.Repeat("ssh a;", maxTokens) + fillerOnly := allocated(func() { sshCommandHosts(filler) }) + var cut bool + withTrailer := allocated(func() { _, cut = sshCommandHosts(text) }) + if delta := int64(withTrailer) - int64(fillerOnly); delta > left*bytesPerToken { + t.Errorf("a line read with %d tokens left added %d KiB on top of the filler's %d KiB; "+ + "want at most %d KiB, the remainder's tokens and no more", + left, delta>>10, fillerOnly>>10, left*bytesPerToken>>10) + } + if !cut { + t.Error("a line read only to the budget left is not reported as cut") + } +} diff --git a/internal/shape/tokenize.go b/internal/shape/tokenize.go index 972a95e5..e900fe88 100644 --- a/internal/shape/tokenize.go +++ b/internal/shape/tokenize.go @@ -52,8 +52,13 @@ const ( // bounded is a lex result held to maxTokens: see errTokenBound. lex stops // at most one token past the bound, and this takes that one back. func bounded(toks []token, err error) ([]token, error) { - if len(toks) > maxTokens { - return toks[:maxTokens], errTokenBound + return boundedTo(toks, err, maxTokens) +} + +// boundedTo is bounded for a lex held to limit tokens rather than maxTokens. +func boundedTo(toks []token, err error, limit int) ([]token, error) { + if len(toks) > limit { + return toks[:limit], errTokenBound } return toks, err } @@ -186,7 +191,16 @@ func (w *word) string(s string) string { // command began, and reads ssh as a program that was never run. Only the // tokenizer knows which it saw, so only the tokenizer can say. func tokenizeMarked(s string) ([]string, []bool, error) { - ts, err := tokenizeShape(s) + return tokenizeMarkedTo(s, maxTokens) +} + +// tokenizeMarkedTo is tokenizeMarked lexing no more than limit tokens, for a +// reader whose budget is shared across lines and has less than maxTokens left +// for this one (sshCommandHosts). A line that goes on past limit ends in +// errTokenBound, as one past maxTokens does. +func tokenizeMarkedTo(s string, limit int) ([]string, []bool, error) { + ts, err := lexTo(joined{s: s}, false, false, limit) + ts, err = boundedTo(ts, err, limit) toks := make([]string, len(ts)) meta := make([]bool, len(ts)) for i, t := range ts { @@ -263,11 +277,17 @@ func tokenizeList(s string) ([]token, error) { // a continuation where it stands. docs selects tokenizeList's reading of // here-documents and comments. func lex(r joined, program, docs bool) ([]token, error) { + return lexTo(r, program, docs, maxTokens) +} + +// lexTo is lex stopping one token past limit rather than past maxTokens, and +// presizing for no more than that; limit is at most maxTokens. +func lexTo(r joined, program, docs bool, limit int) ([]token, error) { s := r.s l := &lexer{s: s, r: r, program: program, docs: docs, w: newWord(), dollarAt: -1} - l.toks = make([]token, 0, tokenEstimate(s)) + l.toks = make([]token, 0, min(tokenEstimate(s), limit+1)) for i := 0; i < len(s); i++ { - if len(l.toks) > maxTokens { + if len(l.toks) > limit { // Past the token bound: see bounded. return l.toks, errTokenBound } diff --git a/test/mutation/mutants/RES-9_token-bound.py b/test/mutation/mutants/RES-9_token-bound.py index 8889e59f..18cf5b66 100644 --- a/test/mutation/mutants/RES-9_token-bound.py +++ b/test/mutation/mutants/RES-9_token-bound.py @@ -6,15 +6,15 @@ m("RES-9 the token bound is lifted", "internal/shape/tokenize.go", "\tmaxTokens = shapeBudget / bytesPerToken\n", "\tmaxTokens = 1 << 30\n", "TestRES9_") m("RES-9 the lexer reads on past the bound", "internal/shape/tokenize.go", - "\t\tif len(l.toks) > maxTokens {\n\t\t\t// Past the token bound: see bounded.\n", + "\t\tif len(l.toks) > limit {\n\t\t\t// Past the token bound: see bounded.\n", "\t\tif false {\n\t\t\t// Past the token bound: see bounded.\n", "TestRES9_TokenBoundCapsShapeMemory") m("RES-9 the token past the bound is handed back", "internal/shape/tokenize.go", - "\t\treturn toks[:maxTokens], errTokenBound\n", "\t\treturn toks, errTokenBound\n", "TestRES9_LexerStopsAtTheBound") + "\t\treturn toks[:limit], errTokenBound\n", "\t\treturn toks, errTokenBound\n", "TestRES9_LexerStopsAtTheBound") m("RES-9 a line read to the bound is read as certain", "internal/shape/shape.go", "\tuncertain := perr == errUncertain || perr == errTokenBound\n", "\tuncertain := perr == errUncertain\n", "TestRES9_TokenBoundCapsShapeMemory") m("RES-9 the token slice is grown, not sized", "internal/shape/tokenize.go", - "\tl.toks = make([]token, 0, tokenEstimate(s))\n", "\tl.toks = []token(nil)\n", "TestRES9_TokenBoundCapsShapeMemory") + "\tl.toks = make([]token, 0, min(tokenEstimate(s), limit+1))\n", "\tl.toks = []token(nil)\n", "TestRES9_TokenBoundCapsShapeMemory") # A line read only to the bound is reported as cut, by Derive and by the ssh # destination reader, so a writer that can carry schema 5's shape.truncated @@ -37,3 +37,19 @@ m("RES-9 HostsCut drops the ssh reader's cut", "internal/shape/hosts.go", "\t\tnamed, cut = sshCommandHosts(text)\n", "\t\tnamed, _ = sshCommandHosts(text)\n", "TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut") + +# The ssh reader's budget is shared across a command's lines, and a line is +# lexed to what is left of it, not to maxTokens: a line reached with a few +# hundred tokens left lexed a whole line's worth again, about 2x the bound. +# Lexing the line to maxTokens, letting the lexer run on to maxTokens under a +# smaller limit, or sizing the token slice for maxTokens under one is each +# the regression. +m("RES-9 an ssh line is lexed to maxTokens, not the budget left", "internal/shape/hosts.go", + "\t\thosts, read, lineCut := sshLineHosts(line, budget)\n", "\t\thosts, read, lineCut := sshLineHosts(line, maxTokens)\n", + "TestRES9_") +m("RES-9 the lexer runs on to maxTokens under a smaller limit", "internal/shape/tokenize.go", + "\t\tif len(l.toks) > limit {\n", "\t\tif len(l.toks) > maxTokens {\n", + "TestRES9_SSHLineIsLexedOnlyToTheBudgetLeft") +m("RES-9 the token slice is sized for maxTokens under a smaller limit", "internal/shape/tokenize.go", + "\tl.toks = make([]token, 0, min(tokenEstimate(s), limit+1))\n", "\tl.toks = make([]token, 0, tokenEstimate(s))\n", + "TestRES9_SSHLineIsLexedOnlyToTheBudgetLeft") From f78dd6109bd66ac62070907abc80b7e88156f19f Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Wed, 21 Oct 2026 23:25:31 -0700 Subject: [PATCH 39/50] shape: DeriveWithDirs names no leading directory on a control-byte line DeriveWithDirs read the leading cds without LeadingDirectory's controlByte guard, so `cd /repo\r && ls` gave ["/repo"] where it used to give none, and the hook folded a directory the shell never enters into the call's cwd digest; \x00, \x07 and \x1b did the same. deriveCut now reads them only for a line with no control byte. TestLeadingDirectory holds DeriveWithDirs to LeadingDirectory's answer on every case, five control-byte lines among them, and TestCaptureKeepsTheCWDOfAControlByteLine checks the hook's cwd digest end to end. Two breaks are registered in TB_shape.py. --- internal/hook/handle_test.go | 43 ++++++++++++++++++++ internal/shape/shape.go | 13 ++++-- internal/shape/verb_test_class_test.go | 17 +++++++- test/mutation/mutants/RES-29_shape-memory.py | 3 +- test/mutation/mutants/TB_shape.py | 13 ++++++ 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/internal/hook/handle_test.go b/internal/hook/handle_test.go index 4153f4d0..316447ab 100644 --- a/internal/hook/handle_test.go +++ b/internal/hook/handle_test.go @@ -2,9 +2,13 @@ package hook import ( "encoding/json" + "fmt" + "strings" "testing" + "time" "github.com/altrace-dev-role/rashomon/internal/shape" + "github.com/altrace-dev-role/rashomon/internal/store" ) // startDirectory folds a line's leading cd targets onto the payload's cwd: @@ -35,3 +39,42 @@ func TestStartDirectory(t *testing.T) { } } } + +// TestCaptureKeepsTheCWDOfAControlByteLine: a line holding a control byte +// moves the call's start directory nowhere, so its cwd digest is the +// payload's cwd. `cd /repo\r && ls` never enters /repo -- the shell reads +// the carriage return as part of the directory's name -- and the hook folded +// it in when it read the leading cds through DeriveWithDirs, which had lost +// LeadingDirectory's guard. The plain line is the control: it does move. +func TestCaptureKeepsTheCWDOfAControlByteLine(t *testing.T) { + t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir()) + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const cwd = "/tmp/project" + for i, tc := range []struct{ cmd, dir string }{ + {"cd /repo && ls", "/repo"}, + {"cd /repo\r && ls", cwd}, + {"cd /repo\x00 && ls", cwd}, + {"cd /repo\x07 && ls", cwd}, + {"cd /repo\x1b && ls", cwd}, + } { + payload, err := json.Marshal(map[string]any{ + "session_id": "s-ctl", "tool_use_id": fmt.Sprintf("toolu_%d", i), "tool_name": "Bash", + "cwd": cwd, "hook_event_name": "PreToolUse", "tool_input": map[string]any{"command": tc.cmd}, + }) + if err != nil { + t.Fatal(err) + } + h := New(st, time.Now) + h.Close(&Signals{}, h.Capture(strings.NewReader(string(payload)))) + run, err := st.ReadRun("s-ctl") + if err != nil || len(run.Declarations) != i+1 { + t.Fatalf("%q: %d declarations, %v", tc.cmd, len(run.Declarations), err) + } + if got, want := run.Declarations[i].CWDDigest, shape.CWDDigest(st.Key(), tc.dir); got != want { + t.Errorf("%q: cwd digest is not %s's", tc.cmd, tc.dir) + } + } +} diff --git a/internal/shape/shape.go b/internal/shape/shape.go index a4aab7f3..8f2b7bae 100644 --- a/internal/shape/shape.go +++ b/internal/shape/shape.go @@ -179,10 +179,11 @@ func deriveCut(toolName string, toolInput json.RawMessage, key []byte) (Shape, b // A line read only to the token bound is as uncertain as one the lexer // stopped in: what follows is unknown, not absent. uncertain := perr == errUncertain || perr == errTokenBound + ctl := controlByte(cmd) // A line whose program cannot be named, or which the lexer could not // read to its end, may write: nobody saw what it runs. s.MayWrite = true - if i, ok := programToken(pshaped, uncertain); ok && !controlByte(cmd) { + if i, ok := programToken(pshaped, uncertain); ok && !ctl { if i, ok = pastDirectoryChange(pshaped, i, uncertain); ok { prog := path.Base(pshaped[i].text) s.Program = &prog @@ -197,8 +198,14 @@ func deriveCut(toolName string, toolInput json.RawMessage, key []byte) (Shape, b n := len(dropLeadingAssignments(toks)) s.Argc = &n } - // Where the line cannot be read, dirs is empty and no step is folded. - dirs, _ := leadingRun(pshaped, perr) + // A line holding a control byte names no directory, as LeadingDirectory + // says: the shell does not split it where the tokenizer does, so `cd + // /repo\r` never enters /repo. Where the line cannot be read, dirs is + // empty too, and no step is folded. + var dirs []string + if !ctl { + dirs, _ = leadingRun(pshaped, perr) + } s.StatusMasked, s.RunnerDigest = statusMasked(cmd, key, leadingSteps(dirs)) cut := err == errTokenBound || perr == errTokenBound || len(dirs) > MaxLeadingCd return s, cut, dirs diff --git a/internal/shape/verb_test_class_test.go b/internal/shape/verb_test_class_test.go index b7648af9..5c9b8192 100644 --- a/internal/shape/verb_test_class_test.go +++ b/internal/shape/verb_test_class_test.go @@ -515,7 +515,10 @@ func TestMayWrite(t *testing.T) { // The directories a runner's line moves through before it starts, when the // line begins with plain literal `cd DIR &&` steps: the hook keys the run on // where they lead rather than on the payload's cwd, which is the shell's -// directory before the cds. +// directory before the cds. The hook reads them through DeriveWithDirs, so +// each case holds it to LeadingDirectory's answer: a line with a control +// byte names no directory to either, since the shell does not split it where +// the tokenizer does. func TestLeadingDirectory(t *testing.T) { for _, tc := range []struct { cmd string @@ -561,11 +564,21 @@ func TestLeadingDirectory(t *testing.T) { {"X=1", nil}, {"cd /a && X=1", []string{"/a"}}, {"cd /repo/web && GOFLAGS=-count=1 go test ./...", []string{"/repo/web"}}, + // A control byte anywhere on the line. + {"cd /repo\r && ls", nil}, + {"cd /repo\x00 && ls", nil}, + {"cd /repo && ls \x07", nil}, + {"cd /repo && \x1b[0m ls", nil}, + {"cd /repo\x7f && ls", nil}, } { - got, ok := LeadingDirectory("Bash", json.RawMessage(`{"command":`+quoteJSON(tc.cmd)+`}`)) + in := json.RawMessage(`{"command":` + quoteJSON(tc.cmd) + `}`) + got, ok := LeadingDirectory("Bash", in) if ok != (tc.want != nil) || !slices.Equal(got, tc.want) { t.Errorf("%q: LeadingDirectory = %q, %v; want %q", tc.cmd, got, ok, tc.want) } + if _, _, dirs := DeriveWithDirs("Bash", in, []byte("key")); !slices.Equal(dirs, tc.want) { + t.Errorf("%q: DeriveWithDirs dirs = %q; want %q, as LeadingDirectory", tc.cmd, dirs, tc.want) + } } if _, ok := LeadingDirectory("mcp__x__run", json.RawMessage(`{"command":"cd /repo && make test"}`)); ok { t.Error("a tool that is not a shell has no leading directory") diff --git a/test/mutation/mutants/RES-29_shape-memory.py b/test/mutation/mutants/RES-29_shape-memory.py index 1b6d0c4f..19941c8a 100644 --- a/test/mutation/mutants/RES-29_shape-memory.py +++ b/test/mutation/mutants/RES-29_shape-memory.py @@ -18,7 +18,7 @@ m("RES-29 a span past the kept offsets is read as certain", "internal/shape/comsub.go", "\treturn r.lost && to >= r.joins[len(r.joins)-1]\n", "\treturn false\n", "TestRES29_") m("RES-29 the leading cds are read again for the masked status", "internal/shape/shape.go", - "\tdirs, _ := leadingRun(pshaped, perr)\n", "\tdirs, _ := leadingDirectories(cmd)\n", "TestRES29_") + "\t\tdirs, _ = leadingRun(pshaped, perr)\n", "\t\tdirs, _ = leadingDirectories(cmd)\n", "TestRES29_") m("RES-29 ssh lines are read past the token bound", "internal/shape/hosts.go", "\tfor rest, budget := text, maxTokens; rest != \"\" && budget > 0; {\n", "\tfor rest, budget := text, maxTokens; rest != \"\"; {\n", "TestRES29_") @@ -33,3 +33,4 @@ m("RES-29 an escaped blank ends a word in the token estimate", "internal/shape/tokenize.go", "\t\tcase c == '\\\\': // the byte it escapes is the word's, a blank included\n\t\t\ti++\n\t\t\tinWord = true\n", "", "TestRES29_") + diff --git a/test/mutation/mutants/TB_shape.py b/test/mutation/mutants/TB_shape.py index 5735891d..48920295 100644 --- a/test/mutation/mutants/TB_shape.py +++ b/test/mutation/mutants/TB_shape.py @@ -713,3 +713,16 @@ "\tif t.Undeclared > 0 {\n\t\tfmt.Fprintf(b, \" no pair is looked for", "\tif false {\n\t\tfmt.Fprintf(b, \" no pair is looked for", "TestTestRuns_") + +# A line holding a control byte names no leading directory to the hook, +# which reads them through DeriveWithDirs, as to LeadingDirectory: the shell +# does not split it where the tokenizer does. Folding its cds is the +# regression. +m("TB DeriveWithDirs folds the leading cds of a control-byte line", "internal/shape/shape.go", + "\tif !ctl {\n\t\tdirs, _ = leadingRun(pshaped, perr)\n\t}\n", + "\tdirs, _ = leadingRun(pshaped, perr)\n", + "TestLeadingDirectory|TestCaptureKeepsTheCWDOfAControlByteLine") +m("TB LeadingDirectory reads the cds of a control-byte line", "internal/shape/leading.go", + "\tif !ok || controlByte(cmd) {\n\t\treturn nil, false\n\t}\n\treturn leadingDirectories(cmd)\n", + "\tif !ok {\n\t\treturn nil, false\n\t}\n\treturn leadingDirectories(cmd)\n", + "TestLeadingDirectory") From 0ef340b817123e1ee41c05446de010059ab87340 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Wed, 21 Oct 2026 23:25:32 -0700 Subject: [PATCH 40/50] ci: run the RES-29 RSS step past a red gate before it The perf-linux RES-29 step had no `if:`, so benchgate's exit 2 while budgets.json has no linux rows skipped it on every run (no output from it in job 118118468807), and the only place RES-29's bound is checked checked nothing. It now carries `if: success() || failure()`. TestRES29_CIChecksTheRSSBoundPastARedGate holds the step to that, and the break is registered in RES-29_shape-memory.py, whose leading-cds anchor follows deriveCut's guarded read. --- .github/workflows/ci.yaml | 5 +++- test/mutation/mutants/RES-29_shape-memory.py | 5 ++++ test/perf/ci_test.go | 25 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c91f7b58..a95a5196 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -434,8 +434,11 @@ jobs: # `test` job's `go test ./... -race` never actually checks it: every # row logs and skips. This is the only place it is checked anywhere, # and the only place linux/amd64's own number (not darwin's) is the one - # compared. + # compared. It runs whatever the steps before it did: a red benchgate + # skipped it, and a bound that is checked only while every other gate + # is green is not checked. - name: RES-29 peak RSS (no race detector) + if: success() || failure() run: go test ./cmd/rashomon -run '^TestRES29_PathologicalInputsStayBounded$' -count=1 -v perf-macos: diff --git a/test/mutation/mutants/RES-29_shape-memory.py b/test/mutation/mutants/RES-29_shape-memory.py index 19941c8a..80c7002e 100644 --- a/test/mutation/mutants/RES-29_shape-memory.py +++ b/test/mutation/mutants/RES-29_shape-memory.py @@ -34,3 +34,8 @@ "\t\tcase c == '\\\\': // the byte it escapes is the word's, a blank included\n\t\t\ti++\n\t\t\tinWord = true\n", "", "TestRES29_") +# The linux perf job is the only place the RSS bound is checked, so its step +# runs past a red gate before it. Dropping its `if:` is the regression. +m("RES-29 the CI RSS step runs only when every gate before it passed", ".github/workflows/ci.yaml", + " - name: RES-29 peak RSS (no race detector)\n if: success() || failure()\n", + " - name: RES-29 peak RSS (no race detector)\n", "TestRES29_CIChecksTheRSSBoundPastARedGate") diff --git a/test/perf/ci_test.go b/test/perf/ci_test.go index efcd4a84..9162b816 100644 --- a/test/perf/ci_test.go +++ b/test/perf/ci_test.go @@ -109,3 +109,28 @@ func TestRES36h_PerfJobsRunPinnedAndFailClosed(t *testing.T) { } } } + +// TestRES29_CIChecksTheRSSBoundPastARedGate: the linux perf job is the only +// place RES-29's peak-RSS bound is checked (the test job runs under -race, +// where it skips), and its step runs whatever the steps before it did. With +// no `if:` it ran only on success, so a benchgate exit -- 2 while +// budgets.json had no linux rows -- skipped it on every run. +func TestRES29_CIChecksTheRSSBoundPastARedGate(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleRoot(t), ".github", "workflows", "ci.yaml")) + if err != nil { + t.Fatal(err) + } + found := false + for _, s := range workflowSteps(string(raw))["perf-linux"] { + if !strings.Contains(s, "TestRES29_PathologicalInputsStayBounded") { + continue + } + found = true + if !regexp.MustCompile(`(?m)^ if: (success\(\) \|\| failure\(\)|always\(\)|\$\{\{ !cancelled\(\) \}\}|!cancelled\(\))\s*$`).MatchString(s) { + t.Errorf("perf-linux: the RES-29 step runs only when every step before it passed:\n%s", s) + } + } + if !found { + t.Error("perf-linux: no step runs TestRES29_PathologicalInputsStayBounded") + } +} From d9fe91ea75eda91806db8ddb2289d83d506bd8ad Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Wed, 21 Oct 2026 23:25:32 -0700 Subject: [PATCH 41/50] baseline: store a session id JSON would escape as its digest maxEntryBytes allowed a 256-byte session id as written, but JSON writes `<`, a control byte or invalid UTF-8 as six bytes: 256 of them took 1,536, and one such id let a baseline fill past maxBaselineBytes at about 2,225 hosts, after which every render's save was refused for good. clampSessionID now keeps an id as it is only when it fits the cap and JSON writes every byte of it as itself, and otherwise stores a 34-byte digest; the same id still gives the same digest on every render. An oversized id stored by an earlier build as a 256-byte prefix will no longer match, which costs that session's novelty once; no harness emits such ids. TestFold_ASessionIDThatEscapesStillFitsAFullBaseline saves a full baseline of 253-byte names first seen by each kind of escaping id; the break is registered in RUBRIC_lld-11_session-id-bound.py. --- internal/baseline/baseline_test.go | 43 ++++++++++++++++++ internal/baseline/fold.go | 44 +++++++++++++------ .../mutants/RUBRIC_lld-11_session-id-bound.py | 7 +++ 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/internal/baseline/baseline_test.go b/internal/baseline/baseline_test.go index d7262e92..5eedc63c 100644 --- a/internal/baseline/baseline_test.go +++ b/internal/baseline/baseline_test.go @@ -1,6 +1,7 @@ package baseline import ( + "fmt" "os" "path/filepath" "reflect" @@ -414,6 +415,48 @@ func TestUpdate_TheEstablisherCorrectsTowardTheEarlierSession(t *testing.T) { } } +// TestFold_ASessionIDThatEscapesStillFitsAFullBaseline: maxEntryBytes +// allows a session id of maxSessionIDBytes as written, but JSON writes `<`, +// a control byte or a byte of invalid UTF-8 as six: 256 of them took 1,536 +// bytes, and one session id like that let a baseline fill past +// maxBaselineBytes at about 2,225 hosts, after which every render's save was +// refused, for good. A full baseline of 253-byte names, every one first seen +// by a session whose id escapes, still saves; the same id still reads as the +// same session on the next render; and an id JSON writes as itself is +// stored as it is. +func TestFold_ASessionIDThatEscapesStillFitsAFullBaseline(t *testing.T) { + label := strings.Repeat("a", 63) + hosts := make([]string, maxBaselineHosts) + for i := range hosts { + first := fmt.Sprintf("%05d", i) + label[5:] + hosts[i] = first + "." + label + "." + label + "." + label[:61] + } + if n := len(hosts[0]); n != 253 { + t.Fatalf("premise: a %d-byte name, want 253", n) + } + for _, id := range []string{ + strings.Repeat("<", maxSessionIDBytes), + strings.Repeat("\x01", maxSessionIDBytes), + strings.Repeat("\xff", maxSessionIDBytes), + } { + f, res, _ := Fold(File{}, false, id, t0, hosts) + if res.Err != nil { + t.Fatalf("%q...: %v", id[:4], res.Err) + } + dir := t.TempDir() + if err := save(dir, filepath.Join(dir, "b.json"), f); err != nil { + t.Errorf("%q...: a full baseline does not save: %v", id[:4], err) + } + if _, res, _ := Fold(f, true, id, t0, hosts[:1]); !res.Established { + t.Errorf("%q...: the same id no longer reads as the establisher", id[:4]) + } + } + const uuid = "0f8e3c2a-6b1d-4c9e-a7f4-2d5b8e1c3a90" + if f, _, _ := Fold(File{}, false, uuid, t0, []string{"a.example"}); f.EstablishedBySession != uuid { + t.Errorf("a plain session id is stored as %q, want it as it is", f.EstablishedBySession) + } +} + // TestFold_SessionIDIsBoundedBeforeStorage: a session id of unbounded length // is not hostname-shaped and nothing here rejects it the way a host is // rejected, so maxEntryBytes's own math -- a 253-byte name, a session id of diff --git a/internal/baseline/fold.go b/internal/baseline/fold.go index 881469ea..75fd790e 100644 --- a/internal/baseline/fold.go +++ b/internal/baseline/fold.go @@ -1,6 +1,8 @@ package baseline import ( + "crypto/sha256" + "encoding/hex" "errors" "io" "maps" @@ -36,24 +38,40 @@ const maxBaselineHosts = 4096 // bound. const maxSessionIDBytes = 256 -// clampSessionID bounds a session id to maxSessionIDBytes, which may cut a -// multi-byte UTF-8 rune in half; that is fine here, because a session id is -// an opaque comparison key to this package, never displayed, and -// encoding/json replaces an invalid trailing sequence with U+FFFD on marshal -// rather than failing to encode. The same input truncates to the same -// bytes every time, so a later render of the same (still oversized) session -// id still compares equal to what an earlier render stored. +// clampSessionID is a session id as this package stores it: as it is when +// it is at most maxSessionIDBytes and JSON writes every byte of it as itself +// (jsonPlain), and otherwise a 34-byte digest of it. A cap on its length +// alone did not bound what save writes: JSON writes `<`, a control byte or a +// byte of invalid UTF-8 as six, so a 256-byte id took 1,536. A session id is +// an opaque comparison key here, never displayed, and the same input gives +// the same digest every time, so a later render of the same session still +// compares equal to what an earlier one stored. func clampSessionID(id string) string { - if len(id) > maxSessionIDBytes { - return id[:maxSessionIDBytes] + if len(id) <= maxSessionIDBytes && jsonPlain(id) { + return id } - return id + sum := sha256.Sum256([]byte(id)) + return "x-" + hex.EncodeToString(sum[:16]) +} + +// jsonPlain reports whether encoding/json writes every byte of s as itself: +// printable ASCII other than the quote and backslash, and the <, > and & it +// escapes for HTML. +func jsonPlain(s string) bool { + for i := 0; i < len(s); i++ { + switch c := s[i]; { + case c < 0x20 || c > 0x7e, c == '"', c == '\\', c == '<', c == '>', c == '&': + return false + } + } + return true } // maxEntryBytes bounds one host's entry as save writes it: a 253-byte name, -// a session id of up to maxSessionIDBytes, two 13-digit stamps, and the -// keys and indentation, about 140 bytes -- 1,024 with room for JSON -// escaping. maxBaselineBytes is the file at the cap of such entries, plus +// a session id of up to maxSessionIDBytes that JSON writes byte for byte +// (clampSessionID), two 13-digit stamps, and the keys and indentation, about +// 140 bytes -- about 680 in all, under 1,024. A host is hostname-shaped +// (host.Shaped), so it is written byte for byte too. maxBaselineBytes is the file at the cap of such entries, plus // 64 KiB for the header and its project key, a path. A file larger than that // is not one this package wrote, and is read as corrupt rather than read // whole; a save that would write one is refused rather than leaving a file diff --git a/test/mutation/mutants/RUBRIC_lld-11_session-id-bound.py b/test/mutation/mutants/RUBRIC_lld-11_session-id-bound.py index 3b3d3070..e18b5829 100644 --- a/test/mutation/mutants/RUBRIC_lld-11_session-id-bound.py +++ b/test/mutation/mutants/RUBRIC_lld-11_session-id-bound.py @@ -4,3 +4,10 @@ m("a session id is not bounded before an entry stores it", "internal/baseline/fold.go", "\tsessionID = clampSessionID(sessionID)\n", "", "TestFold_SessionIDIsBoundedBeforeStorage") + +# A length cap alone does not bound what save writes: JSON writes `<`, a +# control byte or invalid UTF-8 as six bytes. Storing such an id as it is, +# rather than its digest, is the regression. +m("a session id JSON escapes is stored as it is", "internal/baseline/fold.go", + "\tif len(id) <= maxSessionIDBytes && jsonPlain(id) {\n", "\tif len(id) <= maxSessionIDBytes {\n", + "TestFold_ASessionIDThatEscapesStillFitsAFullBaseline") From 581da58d51d1b9dc1f03de94035b529761c2575a Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Thu, 22 Oct 2026 02:44:34 -0700 Subject: [PATCH 42/50] recap: the session-cap bound test passes writeState the session it keeps On main writeState also takes the current session, which capSessions never drops. The test-8 bound test predates that and passes none, so the cut it checks is the plain one. --- internal/recap/rubric_test8_test.go | 2 +- ...-canonical.py => RUBRIC_lld-11_forgotten-check-canonical.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename test/mutation/mutants/{RUBRIC_lld-11b_forgotten-check-canonical.py => RUBRIC_lld-11_forgotten-check-canonical.py} (100%) diff --git a/internal/recap/rubric_test8_test.go b/internal/recap/rubric_test8_test.go index b36f7d03..3791e340 100644 --- a/internal/recap/rubric_test8_test.go +++ b/internal/recap/rubric_test8_test.go @@ -20,7 +20,7 @@ func TestRubricTEST8_TrackedSessionsAtNAndNPlusOne(t *testing.T) { s.Spoken[id], s.Checked[id] = "turn", "turn" } path := filepath.Join(t.TempDir(), stateFileName) - if err := writeState(path, s); err != nil { + if err := writeState(path, s, ""); err != nil { t.Fatal(err) } got, err := readState(path) diff --git a/test/mutation/mutants/RUBRIC_lld-11b_forgotten-check-canonical.py b/test/mutation/mutants/RUBRIC_lld-11_forgotten-check-canonical.py similarity index 100% rename from test/mutation/mutants/RUBRIC_lld-11b_forgotten-check-canonical.py rename to test/mutation/mutants/RUBRIC_lld-11_forgotten-check-canonical.py From 06c7256082184e03198ce204e1c1ccaf5d5f45e0 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Thu, 22 Oct 2026 02:44:34 -0700 Subject: [PATCH 43/50] test: each lld-11 mutant names a test of its own entry (DEL-23) main's sweep refuses a RUBRIC_ mutant that names no TestRubric test of the entry its file claims, and a rubric file not named for one entry. Eight lld-11 mutants named the baseline, report and fold tests that judge them by their old names, and the forgotten-check file claimed "lld-11b". The tests are renamed into lld-11 (the two forgotten-check seams as TestRubricLLD11b_, which the rule reads as lld-11), the file is renamed, and the mutant that also named TestLoad_ keeps every Load test it ran before. --- internal/baseline/baseline_test.go | 8 ++++---- internal/baseline/canonical_test.go | 12 ++++++------ internal/baseline/fold.go | 4 ++-- internal/baseline/forget_all_test.go | 4 ++-- internal/report/destinations_legacy_host_test.go | 4 ++-- internal/report/seam_test.go | 8 ++++---- .../mutants/RUBRIC_lld-11_baseline-canonical.py | 8 ++++---- .../RUBRIC_lld-11_declared-hosts-canonical.py | 2 +- .../RUBRIC_lld-11_forget-all-locked-listing.py | 2 +- .../RUBRIC_lld-11_forgotten-check-canonical.py | 4 ++-- .../mutants/RUBRIC_lld-11_session-id-bound.py | 4 ++-- 11 files changed, 30 insertions(+), 30 deletions(-) diff --git a/internal/baseline/baseline_test.go b/internal/baseline/baseline_test.go index 5eedc63c..130d2fd2 100644 --- a/internal/baseline/baseline_test.go +++ b/internal/baseline/baseline_test.go @@ -415,7 +415,7 @@ func TestUpdate_TheEstablisherCorrectsTowardTheEarlierSession(t *testing.T) { } } -// TestFold_ASessionIDThatEscapesStillFitsAFullBaseline: maxEntryBytes +// TestRubricLLD11_FoldASessionIDThatEscapesStillFitsAFullBaseline: maxEntryBytes // allows a session id of maxSessionIDBytes as written, but JSON writes `<`, // a control byte or a byte of invalid UTF-8 as six: 256 of them took 1,536 // bytes, and one session id like that let a baseline fill past @@ -424,7 +424,7 @@ func TestUpdate_TheEstablisherCorrectsTowardTheEarlierSession(t *testing.T) { // by a session whose id escapes, still saves; the same id still reads as the // same session on the next render; and an id JSON writes as itself is // stored as it is. -func TestFold_ASessionIDThatEscapesStillFitsAFullBaseline(t *testing.T) { +func TestRubricLLD11_FoldASessionIDThatEscapesStillFitsAFullBaseline(t *testing.T) { label := strings.Repeat("a", 63) hosts := make([]string, maxBaselineHosts) for i := range hosts { @@ -457,7 +457,7 @@ func TestFold_ASessionIDThatEscapesStillFitsAFullBaseline(t *testing.T) { } } -// TestFold_SessionIDIsBoundedBeforeStorage: a session id of unbounded length +// TestRubricLLD11_FoldSessionIDIsBoundedBeforeStorage: a session id of unbounded length // is not hostname-shaped and nothing here rejects it the way a host is // rejected, so maxEntryBytes's own math -- a 253-byte name, a session id of // up to 256 bytes, two stamps, keys and indentation, 1,024 with room for @@ -469,7 +469,7 @@ func TestFold_ASessionIDThatEscapesStillFitsAFullBaseline(t *testing.T) { // capacity for real hosts and finds it already spent on one session's id. // Bounding the id at the point it enters a stored entry makes the comment's // own assumption true by construction instead of by convention. -func TestFold_SessionIDIsBoundedBeforeStorage(t *testing.T) { +func TestRubricLLD11_FoldSessionIDIsBoundedBeforeStorage(t *testing.T) { huge := strings.Repeat("s", 10_000) f, res, changed := Fold(File{}, false, huge, t0, []string{"a.example"}) if !changed { diff --git a/internal/baseline/canonical_test.go b/internal/baseline/canonical_test.go index 351c5edf..63a1db4a 100644 --- a/internal/baseline/canonical_test.go +++ b/internal/baseline/canonical_test.go @@ -30,12 +30,12 @@ func writeLegacyBaseline(t *testing.T, root, key string, hosts map[string]entry) return path } -// TestUpdate_LegacyIPLiteralIsNotNovelAgain: a baseline written before +// TestRubricLLD11_UpdateLegacyIPLiteralIsNotNovelAgain: a baseline written before // host.Canonical folded an IP literal to its one spelling holds the // uncanonicalised string. A session naming the same host in its canonical // form must not read as novel, and the two keys must merge into one host, // not two. -func TestUpdate_LegacyIPLiteralIsNotNovelAgain(t *testing.T) { +func TestRubricLLD11_UpdateLegacyIPLiteralIsNotNovelAgain(t *testing.T) { root := t.TempDir() writeLegacyBaseline(t, root, "/proj", map[string]entry{ "167838211": {FirstSeenSession: "sess-0", FirstSeenAtUnixMS: t0.UnixMilli()}, @@ -51,7 +51,7 @@ func TestUpdate_LegacyIPLiteralIsNotNovelAgain(t *testing.T) { } } -// TestLoad_MergesCollidingLegacyKeys: two pre-upgrade keys that canonicalise +// TestRubricLLD11_LoadMergesCollidingLegacyKeys: two pre-upgrade keys that canonicalise // to the same host merge into one entry, keeping the earlier // FirstSeenSession and the later LastSeenAtUnixMS, as Fold's own ownership // rule decides both. @@ -60,7 +60,7 @@ func TestUpdate_LegacyIPLiteralIsNotNovelAgain(t *testing.T) { // has to correct away from it: the later session landed in the map first, // and the earlier one -- read second -- has to displace it, which is the // direction a map walked in insertion order would never need to. -func TestLoad_MergesCollidingLegacyKeys(t *testing.T) { +func TestRubricLLD11_LoadMergesCollidingLegacyKeys(t *testing.T) { root := t.TempDir() path := writeLegacyBaseline(t, root, "/proj", map[string]entry{ // "10.1.2.3" sorts first and carries the later FirstSeenAtUnixMS and @@ -94,10 +94,10 @@ func TestLoad_MergesCollidingLegacyKeys(t *testing.T) { } } -// TestLoad_RefusesAnUnshapedLegacyKey: a key host.Canonical maps to +// TestRubricLLD11_LoadRefusesAnUnshapedLegacyKey: a key host.Canonical maps to // something host.Shaped refuses -- or that it refuses outright -- is // dropped rather than carried forward under a new spelling. -func TestLoad_RefusesAnUnshapedLegacyKey(t *testing.T) { +func TestRubricLLD11_LoadRefusesAnUnshapedLegacyKey(t *testing.T) { root := t.TempDir() path := writeLegacyBaseline(t, root, "/proj", map[string]entry{ "api.example.com=ghp_x": {FirstSeenSession: "sess-0", FirstSeenAtUnixMS: t0.UnixMilli()}, diff --git a/internal/baseline/fold.go b/internal/baseline/fold.go index 75fd790e..9feb8452 100644 --- a/internal/baseline/fold.go +++ b/internal/baseline/fold.go @@ -270,8 +270,8 @@ func canonicalizeHosts(hosts map[string]entry) map[string]entry { // folding "1234" to 0.0.4.210 here would do on every load exactly // what fixing the ssh extraction path alone could not undo. A // legacy entry with no field at all is SSH == false and keeps the - // fold, which is what TestUpdate_LegacyIPLiteralIsNotNovelAgain and - // TestLoad_MergesCollidingLegacyKeys pin for a file old enough to + // fold, which is what TestRubricLLD11_UpdateLegacyIPLiteralIsNotNovelAgain and + // TestRubricLLD11_LoadMergesCollidingLegacyKeys pin for a file old enough to // predate the distinction. canonicalize := host.Canonical if e.SSH { diff --git a/internal/baseline/forget_all_test.go b/internal/baseline/forget_all_test.go index 57f92262..8c46b0ab 100644 --- a/internal/baseline/forget_all_test.go +++ b/internal/baseline/forget_all_test.go @@ -43,7 +43,7 @@ func TestForgetAll_ListingIsUnderTheLock(t *testing.T) { } } -// TestForgetAll_SeesAFileCreatedWhileItWasBlockedOnTheLock makes the race the +// TestRubricLLD11_ForgetAllSeesAFileCreatedWhileItWasBlockedOnTheLock makes the race the // previous test proves impossible concrete and deterministic: a project // baseline that comes into existence WHILE ForgetAll is waiting for the // lock must still be visited once it gets in. A version that lists the @@ -52,7 +52,7 @@ func TestForgetAll_ListingIsUnderTheLock(t *testing.T) { // known it was there -- not because it raced and lost, but because listing // before locking means there is nothing to race: the file simply postdates // the decision of what to look at. -func TestForgetAll_SeesAFileCreatedWhileItWasBlockedOnTheLock(t *testing.T) { +func TestRubricLLD11_ForgetAllSeesAFileCreatedWhileItWasBlockedOnTheLock(t *testing.T) { root := t.TempDir() dir := filepath.Join(root, "baseline") if err := os.MkdirAll(dir, 0o700); err != nil { diff --git a/internal/report/destinations_legacy_host_test.go b/internal/report/destinations_legacy_host_test.go index ac4aa245..de3dba72 100644 --- a/internal/report/destinations_legacy_host_test.go +++ b/internal/report/destinations_legacy_host_test.go @@ -6,12 +6,12 @@ import ( "github.com/altrace-dev-role/rashomon/internal/store" ) -// TestNovelty_LegacyIPLiteralDeclarationIsNotNovelAgain: a run recorded +// TestRubricLLD11_NoveltyLegacyIPLiteralDeclarationIsNotNovelAgain: a run recorded // before host.Canonical folded an IP literal to its one spelling holds the // uncanonicalised string. Read today, under the one spelling a current // build now writes for the same host, it must not read as a new // destination. -func TestNovelty_LegacyIPLiteralDeclarationIsNotNovelAgain(t *testing.T) { +func TestRubricLLD11_NoveltyLegacyIPLiteralDeclarationIsNotNovelAgain(t *testing.T) { project := t.TempDir() root := t.TempDir() mk := func(id string, at int64, hosts ...string) *store.Run { diff --git a/internal/report/seam_test.go b/internal/report/seam_test.go index 544f03de..a7e06482 100644 --- a/internal/report/seam_test.go +++ b/internal/report/seam_test.go @@ -785,7 +785,7 @@ func legacySpellingOutlivesAForget(t *testing.T, id, toolUseID string, hosts, ss return rep } -// TestSeam_ForgetFindsADeclarationStoredInALegacySpelling: a declaration +// TestRubricLLD11b_ForgetFindsADeclarationStoredInALegacySpelling: a declaration // written before host.Canonical folded an IP literal to its one spelling // holds the raw string -- "167838211" where a current build would have // written "10.1.2.3" for the same curl. declaredHostsFiltered checked @@ -794,7 +794,7 @@ func legacySpellingOutlivesAForget(t *testing.T, id, toolUseID string, hosts, ss // under the CANONICAL name, never the stored one -- could not recognise the // legacy declaration as the host it was asked to forget, and it kept // reappearing under destinations.declared_hosts on every later render. -func TestSeam_ForgetFindsADeclarationStoredInALegacySpelling(t *testing.T) { +func TestRubricLLD11b_ForgetFindsADeclarationStoredInALegacySpelling(t *testing.T) { rep := legacySpellingOutlivesAForget(t, "seam-legacy-host", "toolu_legacy", []string{"167838211"}, nil, "10.1.2.3") if got := rep.Sessions[0].Destinations.DeclaredHosts; slices.Contains(got, "167838211") || slices.Contains(got, "10.1.2.3") { @@ -803,14 +803,14 @@ func TestSeam_ForgetFindsADeclarationStoredInALegacySpelling(t *testing.T) { } } -// TestSeam_ForgetFindsAnSSHDeclarationStoredInALegacySpelling: the same hole, +// TestRubricLLD11b_ForgetFindsAnSSHDeclarationStoredInALegacySpelling: the same hole, // for sshDeclaredFiltered -- which, unlike declaredHostsFiltered, did not // canonicalise an ssh host at all before comparing it to forgotten() or // before adding it to the output set. A declaration recorded in mixed case, // from before this build's ssh extraction ran every host through // host.Canonical, never matched the canonical digest `forget --host` // records. -func TestSeam_ForgetFindsAnSSHDeclarationStoredInALegacySpelling(t *testing.T) { +func TestRubricLLD11b_ForgetFindsAnSSHDeclarationStoredInALegacySpelling(t *testing.T) { rep := legacySpellingOutlivesAForget(t, "seam-legacy-ssh-host", "toolu_legacy_ssh", nil, []string{"GitHub.com"}, "github.com") if got := rep.Sessions[0].Destinations.SSHHosts; slices.Contains(got, "GitHub.com") || slices.Contains(got, "github.com") { diff --git a/test/mutation/mutants/RUBRIC_lld-11_baseline-canonical.py b/test/mutation/mutants/RUBRIC_lld-11_baseline-canonical.py index e8154719..312df471 100644 --- a/test/mutation/mutants/RUBRIC_lld-11_baseline-canonical.py +++ b/test/mutation/mutants/RUBRIC_lld-11_baseline-canonical.py @@ -5,13 +5,13 @@ # merge is the regression. m("RUBRIC lld-11 load does not re-key legacy hosts", "internal/baseline/baseline.go", "\tf.Hosts = canonicalizeHosts(f.Hosts)\n\treturn f, true, nil\n", - "\treturn f, true, nil\n", "TestUpdate_LegacyIPLiteralIsNotNovelAgain|TestLoad_") + "\treturn f, true, nil\n", "TestRubricLLD11_UpdateLegacyIPLiteralIsNotNovelAgain|TestRubricLLD11_Load|TestLoad_") m("RUBRIC lld-11 an unshaped legacy key is kept", "internal/baseline/fold.go", "\t\tcanon, ok := canonicalize(h)\n\t\tif !ok || !host.Shaped(canon) {\n\t\t\tcontinue\n\t\t}\n", - "\t\tcanon, ok := canonicalize(h)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n", "TestLoad_RefusesAnUnshapedLegacyKey") + "\t\tcanon, ok := canonicalize(h)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n", "TestRubricLLD11_LoadRefusesAnUnshapedLegacyKey") m("RUBRIC lld-11 a colliding legacy key loses the later LastSeenAtUnixMS", "internal/baseline/fold.go", "\t\tnext.LastSeenAtUnixMS = max(lastSeen(prev), lastSeen(e))\n", - "\t\tnext.LastSeenAtUnixMS = lastSeen(prev)\n", "TestLoad_MergesCollidingLegacyKeys") + "\t\tnext.LastSeenAtUnixMS = lastSeen(prev)\n", "TestRubricLLD11_LoadMergesCollidingLegacyKeys") m("RUBRIC lld-11 a colliding legacy key loses the earlier FirstSeenSession", "internal/baseline/fold.go", "\t\tif e.FirstSeenAtUnixMS > 0 && (prev.FirstSeenAtUnixMS == 0 || e.FirstSeenAtUnixMS < prev.FirstSeenAtUnixMS) {\n", - "\t\tif false {\n", "TestLoad_MergesCollidingLegacyKeys") + "\t\tif false {\n", "TestRubricLLD11_LoadMergesCollidingLegacyKeys") diff --git a/test/mutation/mutants/RUBRIC_lld-11_declared-hosts-canonical.py b/test/mutation/mutants/RUBRIC_lld-11_declared-hosts-canonical.py index 7c6e0095..9e7b17f1 100644 --- a/test/mutation/mutants/RUBRIC_lld-11_declared-hosts-canonical.py +++ b/test/mutation/mutants/RUBRIC_lld-11_declared-hosts-canonical.py @@ -16,4 +16,4 @@ "\t\t\t// escape the check and reappear in every later render of the\n" "\t\t\t// same old run.\n" "\t\t\tif forgotten != nil && forgotten(h, d.RecordedAtMS) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[h] = true\n", - "TestNovelty_LegacyIPLiteralDeclarationIsNotNovelAgain|TestNovelty_RerenderingAPreUpgradeRunIsNotNovel") + "TestRubricLLD11_NoveltyLegacyIPLiteralDeclarationIsNotNovelAgain|TestNovelty_RerenderingAPreUpgradeRunIsNotNovel") diff --git a/test/mutation/mutants/RUBRIC_lld-11_forget-all-locked-listing.py b/test/mutation/mutants/RUBRIC_lld-11_forget-all-locked-listing.py index acae804e..4122c3b9 100644 --- a/test/mutation/mutants/RUBRIC_lld-11_forget-all-locked-listing.py +++ b/test/mutation/mutants/RUBRIC_lld-11_forget-all-locked-listing.py @@ -25,4 +25,4 @@ "\t}\n" "\tdefer release()\n" "\terr = listErr\n", - "TestForgetAll_SeesAFileCreatedWhileItWasBlockedOnTheLock") + "TestRubricLLD11_ForgetAllSeesAFileCreatedWhileItWasBlockedOnTheLock") diff --git a/test/mutation/mutants/RUBRIC_lld-11_forgotten-check-canonical.py b/test/mutation/mutants/RUBRIC_lld-11_forgotten-check-canonical.py index 50b30e5b..327764c0 100644 --- a/test/mutation/mutants/RUBRIC_lld-11_forgotten-check-canonical.py +++ b/test/mutation/mutants/RUBRIC_lld-11_forgotten-check-canonical.py @@ -18,7 +18,7 @@ "\t\t\t// same old run.\n" "\t\t\tcanon, ok := host.Canonical(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n" "\t\t\tif forgotten != nil && forgotten(h, d.RecordedAtMS) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[canon] = true\n", - "TestSeam_ForgetFindsADeclarationStoredInALegacySpelling") + "TestRubricLLD11b_ForgetFindsADeclarationStoredInALegacySpelling") m("RUBRIC lld-11b sshDeclaredFiltered asks forgotten about the stored spelling", "internal/report/destinations.go", "for _, h := range d.SSHHosts {\n" "\t\t\tcanon, ok := host.CanonicalName(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n" @@ -26,4 +26,4 @@ "for _, h := range d.SSHHosts {\n" "\t\t\tcanon, ok := host.CanonicalName(h)\n\t\t\tif !ok || !host.Shaped(canon) {\n\t\t\t\tcontinue\n\t\t\t}\n" "\t\t\tif forgotten != nil && forgotten(h, d.RecordedAtMS) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tset[canon] = true\n", - "TestSeam_ForgetFindsAnSSHDeclarationStoredInALegacySpelling") + "TestRubricLLD11b_ForgetFindsAnSSHDeclarationStoredInALegacySpelling") diff --git a/test/mutation/mutants/RUBRIC_lld-11_session-id-bound.py b/test/mutation/mutants/RUBRIC_lld-11_session-id-bound.py index e18b5829..8a0e19f9 100644 --- a/test/mutation/mutants/RUBRIC_lld-11_session-id-bound.py +++ b/test/mutation/mutants/RUBRIC_lld-11_session-id-bound.py @@ -3,11 +3,11 @@ # regression. m("a session id is not bounded before an entry stores it", "internal/baseline/fold.go", "\tsessionID = clampSessionID(sessionID)\n", "", - "TestFold_SessionIDIsBoundedBeforeStorage") + "TestRubricLLD11_FoldSessionIDIsBoundedBeforeStorage") # A length cap alone does not bound what save writes: JSON writes `<`, a # control byte or invalid UTF-8 as six bytes. Storing such an id as it is, # rather than its digest, is the regression. m("a session id JSON escapes is stored as it is", "internal/baseline/fold.go", "\tif len(id) <= maxSessionIDBytes && jsonPlain(id) {\n", "\tif len(id) <= maxSessionIDBytes {\n", - "TestFold_ASessionIDThatEscapesStillFitsAFullBaseline") + "TestRubricLLD11_FoldASessionIDThatEscapesStillFitsAFullBaseline") From ba74631c4994c8444458bf5f78d5bddc727d18e8 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Thu, 22 Oct 2026 02:52:15 -0700 Subject: [PATCH 44/50] perf: measure shape-bounds' rows under go1.27.3 on this tree All 34 rows are the best of three at -benchtime 20x under go1.27.3 on this tree, which now sits on store-limits and hook-install, and each entry's alloc_bytes_max follows its benchmark. The two mutants anchored on the hook floor's allocation figure, and RES-36's margin mutant on the bash_short row, follow the rows. --- internal/budget/budgets_gen.go | 2 +- test/perf/budgets.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/budget/budgets_gen.go b/internal/budget/budgets_gen.go index 89145218..68f738c6 100644 --- a/internal/budget/budgets_gen.go +++ b/internal/budget/budgets_gen.go @@ -3,4 +3,4 @@ package budget // tableJSON is test/perf/budgets.json, compacted; Table decodes it on first use. -const tableJSON = "{\"budgets_version\":1,\"go_version\":\"go1.27.3\",\"benchmark_rows\":\"the measured best of three on the reference machine (darwin/arm64, go1.27.3, -benchtime 20x, 2026-10-22); a row's max equals its measurement, and the gate's ten percent (scripts/benchgate) is the only margin\",\"benchmark_platform\":\"darwin/arm64\",\"platforms\":{\"darwin/arm64\":{\"reference\":\"Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \\\"-s -w\\\")\",\"set_from\":\"measured 2026-10-13 on the P-06 tree (archive 85.1.2)\",\"tolerance\":0.15,\"tolerance_until\":\"2026-11-06\",\"tolerance_after\":0.1,\"entries\":{},\"case_budgets\":{}},\"linux/amd64\":{\"reference\":\"ubuntu-latest\",\"set_from\":\"measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on d2071b6 by the measure-linux-benchmarks job, run 39762603829 (best of three, -benchtime 20x)\",\"tolerance\":0.1,\"tolerance_until\":\"\",\"tolerance_after\":0.1,\"entries\":{\"hook.bash_short\":{\"p50\":7.71,\"p95\":7.71},\"post.bash_short\":{\"p50\":7.65,\"p95\":7.65},\"hook.edit_50k\":{\"p50\":9.67,\"p95\":9.67},\"post.edit_314k\":{\"p50\":7.83,\"p95\":7.83},\"recap.stop\":{\"p50\":8.07,\"p95\":8.07},\"recap.pending\":{\"p50\":7.93,\"p95\":7.93},\"probe.start\":{\"p50\":7.64,\"p95\":7.64},\"probe.end\":{\"p50\":7.66,\"p95\":7.66}},\"case_budgets\":{\"hook.400B\":7.64,\"hook.40KB\":7.7,\"post.540B\":7.65,\"post.40KB\":7.65},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":4624,\"measured_allocs_per_op\":71,\"bytes_per_op_max\":4624,\"allocs_per_op_max\":71},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":430689,\"measured_allocs_per_op\":2333,\"bytes_per_op_max\":430689,\"allocs_per_op_max\":2333},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":16550,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":16550,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1885613,\"measured_allocs_per_op\":6829,\"bytes_per_op_max\":1885613,\"allocs_per_op_max\":6829},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8417557,\"measured_allocs_per_op\":31710,\"bytes_per_op_max\":8417557,\"allocs_per_op_max\":31710},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36038866,\"measured_allocs_per_op\":122027,\"bytes_per_op_max\":36038866,\"allocs_per_op_max\":122027},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235463295,\"measured_allocs_per_op\":696851,\"bytes_per_op_max\":235463295,\"allocs_per_op_max\":696851},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":50194,\"measured_allocs_per_op\":336,\"bytes_per_op_max\":50194,\"allocs_per_op_max\":336},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":213832,\"measured_allocs_per_op\":282,\"bytes_per_op_max\":213832,\"allocs_per_op_max\":282},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":43419,\"measured_allocs_per_op\":263,\"bytes_per_op_max\":43419,\"allocs_per_op_max\":263},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":885698,\"measured_allocs_per_op\":262,\"bytes_per_op_max\":885698,\"allocs_per_op_max\":262},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":261837,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261837,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1072366,\"measured_allocs_per_op\":3840,\"bytes_per_op_max\":1072366,\"allocs_per_op_max\":3840},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9506120,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9506120,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":260018,\"measured_allocs_per_op\":556,\"bytes_per_op_max\":260018,\"allocs_per_op_max\":556},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9503936,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9503936,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":14273,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14273,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":14778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":432603,\"measured_allocs_per_op\":2948,\"bytes_per_op_max\":432603,\"allocs_per_op_max\":2948},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10061,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10061,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":10407,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":10407,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":440815,\"measured_allocs_per_op\":2946,\"bytes_per_op_max\":440815,\"allocs_per_op_max\":2946},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":440657,\"measured_allocs_per_op\":2945,\"bytes_per_op_max\":440657,\"allocs_per_op_max\":2945},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163584,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163584,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}]}},\"entries\":[{\"id\":\"hook.bash_short\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"bash_short\",\"payload_bytes\":400,\"benchmark\":\"BenchmarkRunHook/bash_short\",\"alloc_bytes_max\":54058,\"maxrss_mib_p50\":5.95,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":10,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths\"},{\"id\":\"hook.edit_50k\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"edit_50k\",\"payload_bytes\":51200,\"benchmark\":\"BenchmarkRunHook/edit_50k\",\"alloc_bytes_max\":218378,\"maxrss_mib_p50\":6.20,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":25,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB\"},{\"id\":\"post.bash_short\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"bash_short\",\"payload_bytes\":540,\"benchmark\":\"BenchmarkRunPost/bash_short\",\"alloc_bytes_max\":46893,\"maxrss_mib_p50\":5.94,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":11,\"wall_ms_p95\":18,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.94 / 5.89 / 6.00 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"post.edit_314k\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"edit_314k\",\"payload_bytes\":314000,\"benchmark\":\"BenchmarkRunPost/edit_314k\",\"alloc_bytes_max\":892802,\"maxrss_mib_p50\":6.73,\"maxrss_mib_p95\":7.16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":12,\"wall_ms_p95\":22,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.73 / 6.67 / 6.73 MiB); p95 is RES-28's line at 314 KB (7.16 MiB), which the archive applies from P-10 in place of 8 / 10 MiB; 60 runs gave p95 6.92, max 6.95\"},{\"id\":\"recap.stop\",\"events\":[\"Stop\",\"StopFailure\"],\"command\":[\"recap\"],\"payload_class\":\"stop_100\",\"payload_bytes\":2048,\"benchmark\":\"BenchmarkRecap/100\",\"alloc_bytes_max\":1075559,\"maxrss_mib_p50\":7.06,\"maxrss_mib_p95\":16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":20,\"wall_ms_p95\":60,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (7.06 / 7.03 / 7.06 MiB); on the 100-call session BenchmarkRecap/100 reads; expected per archive 85.4.1: 12 / 16 MiB, flat in session size\"},{\"id\":\"recap.pending\",\"events\":[\"UserPromptSubmit\"],\"command\":[\"recap\",\"pending\"],\"payload_class\":\"prompt\",\"payload_bytes\":256,\"benchmark\":\"BenchmarkRecapPending/10\",\"alloc_bytes_max\":261190,\"maxrss_mib_p50\":6.06,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":8,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.09 / 6.06 / 5.98 MiB); on the 10-call session BenchmarkRecapPending/10 reads (the earlier 5.2 measured the no-store early return); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"probe.start\",\"events\":[\"SessionStart\"],\"command\":[\"probe\",\"start\"],\"payload_class\":\"session_start\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.97,\"maxrss_mib_p95\":24,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":400,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.02 / 5.97 / 5.91 MiB); nothing queued; expected per archive 85.4.1: 8 MiB with nothing queued at p50, mining and briefing at p95\"},{\"id\":\"probe.end\",\"events\":[\"SessionEnd\"],\"command\":[\"probe\",\"end\"],\"payload_class\":\"session_end\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.98,\"maxrss_mib_p95\":6.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":6,\"wall_ms_p95\":12,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.95 / 6.05 / 5.98 MiB); closing a started session; expected per archive 85.4.1: one record, 6 / 6.5 MiB\"}],\"rss_cases\":[{\"id\":\"hook.400B\",\"entry\":\"hook\",\"payload_bytes\":400,\"budget_mib\":6.5008,\"measured_mib\":5.97,\"gates_from\":\"P-08\"},{\"id\":\"hook.40KB\",\"entry\":\"hook\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.12,\"gates_from\":\"P-08\"},{\"id\":\"hook.1MiB\",\"entry\":\"hook\",\"payload_bytes\":1048576,\"budget_mib\":9.9,\"measured_mib\":9.48,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.4MiB\",\"entry\":\"hook\",\"payload_bytes\":4194304,\"budget_mib\":20.1,\"measured_mib\":19.72,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.8MiB\",\"entry\":\"hook\",\"payload_bytes\":8388608,\"budget_mib\":33.7,\"measured_mib\":31.88,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.540B\",\"entry\":\"post\",\"payload_bytes\":540,\"budget_mib\":6.5011,\"measured_mib\":6.06,\"gates_from\":\"P-08\"},{\"id\":\"post.40KB\",\"entry\":\"post\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.05,\"gates_from\":\"P-08\"},{\"id\":\"post.1MiB\",\"entry\":\"post\",\"payload_bytes\":1048576,\"budget_mib\":8.95,\"measured_mib\":8.86,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.4MiB\",\"entry\":\"post\",\"payload_bytes\":4194304,\"budget_mib\":16.3,\"measured_mib\":15.42,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.8MiB\",\"entry\":\"post\",\"payload_bytes\":8388608,\"budget_mib\":26.1,\"measured_mib\":23.66,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"}],\"per_tool_call\":{\"memory_time_mib_ms_max\":379,\"summed_peak_mib_max\":32,\"process_creations_max\":{\"settings\":6,\"plugin\":6}},\"parallel\":{\"ways\":64,\"summed_peak_mib_max\":704,\"basis\":\"per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured\"},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":2656,\"measured_allocs_per_op\":33,\"bytes_per_op_max\":2656,\"allocs_per_op_max\":33},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":285328,\"measured_allocs_per_op\":229,\"bytes_per_op_max\":285328,\"allocs_per_op_max\":229},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":17838,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":17838,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1886832,\"measured_allocs_per_op\":6828,\"bytes_per_op_max\":1886832,\"allocs_per_op_max\":6828},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8422345,\"measured_allocs_per_op\":31708,\"bytes_per_op_max\":8422345,\"allocs_per_op_max\":31708},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36024352,\"measured_allocs_per_op\":121991,\"bytes_per_op_max\":36024352,\"allocs_per_op_max\":121991},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235467679,\"measured_allocs_per_op\":696819,\"bytes_per_op_max\":235467679,\"allocs_per_op_max\":696819},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":54058,\"measured_allocs_per_op\":362,\"bytes_per_op_max\":54058,\"allocs_per_op_max\":362},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":218378,\"measured_allocs_per_op\":310,\"bytes_per_op_max\":218378,\"allocs_per_op_max\":310},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":46893,\"measured_allocs_per_op\":290,\"bytes_per_op_max\":46893,\"allocs_per_op_max\":290},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":892802,\"measured_allocs_per_op\":295,\"bytes_per_op_max\":892802,\"allocs_per_op_max\":295},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":263952,\"measured_allocs_per_op\":558,\"bytes_per_op_max\":263952,\"allocs_per_op_max\":558},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1075559,\"measured_allocs_per_op\":3849,\"bytes_per_op_max\":1075559,\"allocs_per_op_max\":3849},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9513616,\"measured_allocs_per_op\":35359,\"bytes_per_op_max\":9513616,\"allocs_per_op_max\":35359},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":261190,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261190,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9511308,\"measured_allocs_per_op\":35356,\"bytes_per_op_max\":9511308,\"allocs_per_op_max\":35356},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":15778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":15778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":16426,\"measured_allocs_per_op\":83,\"bytes_per_op_max\":16426,\"allocs_per_op_max\":83},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":483310,\"measured_allocs_per_op\":3142,\"bytes_per_op_max\":483310,\"allocs_per_op_max\":3142},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10646,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10646,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":11351,\"measured_allocs_per_op\":51,\"bytes_per_op_max\":11351,\"allocs_per_op_max\":51},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":478744,\"measured_allocs_per_op\":2949,\"bytes_per_op_max\":478744,\"allocs_per_op_max\":2949},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":499297,\"measured_allocs_per_op\":3138,\"bytes_per_op_max\":499297,\"allocs_per_op_max\":3138},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163589,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163589,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459141,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459141,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}],\"growth\":[],\"disk\":{\"bytes_per_call_max\":6000}}" +const tableJSON = "{\"budgets_version\":1,\"go_version\":\"go1.27.3\",\"benchmark_rows\":\"the measured best of three on the reference machine (darwin/arm64, go1.27.3, -benchtime 20x, 2026-10-22); a row's max equals its measurement, and the gate's ten percent (scripts/benchgate) is the only margin\",\"benchmark_platform\":\"darwin/arm64\",\"platforms\":{\"darwin/arm64\":{\"reference\":\"Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \\\"-s -w\\\")\",\"set_from\":\"measured 2026-10-13 on the P-06 tree (archive 85.1.2)\",\"tolerance\":0.15,\"tolerance_until\":\"2026-11-06\",\"tolerance_after\":0.1,\"entries\":{},\"case_budgets\":{}},\"linux/amd64\":{\"reference\":\"ubuntu-latest\",\"set_from\":\"measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on d2071b6 by the measure-linux-benchmarks job, run 39762603829 (best of three, -benchtime 20x)\",\"tolerance\":0.1,\"tolerance_until\":\"\",\"tolerance_after\":0.1,\"entries\":{\"hook.bash_short\":{\"p50\":7.71,\"p95\":7.71},\"post.bash_short\":{\"p50\":7.65,\"p95\":7.65},\"hook.edit_50k\":{\"p50\":9.67,\"p95\":9.67},\"post.edit_314k\":{\"p50\":7.83,\"p95\":7.83},\"recap.stop\":{\"p50\":8.07,\"p95\":8.07},\"recap.pending\":{\"p50\":7.93,\"p95\":7.93},\"probe.start\":{\"p50\":7.64,\"p95\":7.64},\"probe.end\":{\"p50\":7.66,\"p95\":7.66}},\"case_budgets\":{\"hook.400B\":7.64,\"hook.40KB\":7.7,\"post.540B\":7.65,\"post.40KB\":7.65},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":4624,\"measured_allocs_per_op\":71,\"bytes_per_op_max\":4624,\"allocs_per_op_max\":71},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":430689,\"measured_allocs_per_op\":2333,\"bytes_per_op_max\":430689,\"allocs_per_op_max\":2333},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":16550,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":16550,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1885613,\"measured_allocs_per_op\":6829,\"bytes_per_op_max\":1885613,\"allocs_per_op_max\":6829},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8417557,\"measured_allocs_per_op\":31710,\"bytes_per_op_max\":8417557,\"allocs_per_op_max\":31710},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36038866,\"measured_allocs_per_op\":122027,\"bytes_per_op_max\":36038866,\"allocs_per_op_max\":122027},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235463295,\"measured_allocs_per_op\":696851,\"bytes_per_op_max\":235463295,\"allocs_per_op_max\":696851},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":50194,\"measured_allocs_per_op\":336,\"bytes_per_op_max\":50194,\"allocs_per_op_max\":336},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":213832,\"measured_allocs_per_op\":282,\"bytes_per_op_max\":213832,\"allocs_per_op_max\":282},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":43419,\"measured_allocs_per_op\":263,\"bytes_per_op_max\":43419,\"allocs_per_op_max\":263},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":885698,\"measured_allocs_per_op\":262,\"bytes_per_op_max\":885698,\"allocs_per_op_max\":262},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":261837,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261837,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1072366,\"measured_allocs_per_op\":3840,\"bytes_per_op_max\":1072366,\"allocs_per_op_max\":3840},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9506120,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9506120,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":260018,\"measured_allocs_per_op\":556,\"bytes_per_op_max\":260018,\"allocs_per_op_max\":556},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9503936,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9503936,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":14273,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14273,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":14778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":432603,\"measured_allocs_per_op\":2948,\"bytes_per_op_max\":432603,\"allocs_per_op_max\":2948},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10061,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10061,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":10407,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":10407,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":440815,\"measured_allocs_per_op\":2946,\"bytes_per_op_max\":440815,\"allocs_per_op_max\":2946},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":440657,\"measured_allocs_per_op\":2945,\"bytes_per_op_max\":440657,\"allocs_per_op_max\":2945},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163584,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163584,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}]}},\"entries\":[{\"id\":\"hook.bash_short\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"bash_short\",\"payload_bytes\":400,\"benchmark\":\"BenchmarkRunHook/bash_short\",\"alloc_bytes_max\":54058,\"maxrss_mib_p50\":5.95,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":10,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths\"},{\"id\":\"hook.edit_50k\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"edit_50k\",\"payload_bytes\":51200,\"benchmark\":\"BenchmarkRunHook/edit_50k\",\"alloc_bytes_max\":218378,\"maxrss_mib_p50\":6.20,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":25,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB\"},{\"id\":\"post.bash_short\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"bash_short\",\"payload_bytes\":540,\"benchmark\":\"BenchmarkRunPost/bash_short\",\"alloc_bytes_max\":46893,\"maxrss_mib_p50\":5.94,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":11,\"wall_ms_p95\":18,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.94 / 5.89 / 6.00 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"post.edit_314k\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"edit_314k\",\"payload_bytes\":314000,\"benchmark\":\"BenchmarkRunPost/edit_314k\",\"alloc_bytes_max\":892802,\"maxrss_mib_p50\":6.73,\"maxrss_mib_p95\":7.16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":12,\"wall_ms_p95\":22,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.73 / 6.67 / 6.73 MiB); p95 is RES-28's line at 314 KB (7.16 MiB), which the archive applies from P-10 in place of 8 / 10 MiB; 60 runs gave p95 6.92, max 6.95\"},{\"id\":\"recap.stop\",\"events\":[\"Stop\",\"StopFailure\"],\"command\":[\"recap\"],\"payload_class\":\"stop_100\",\"payload_bytes\":2048,\"benchmark\":\"BenchmarkRecap/100\",\"alloc_bytes_max\":1075559,\"maxrss_mib_p50\":7.06,\"maxrss_mib_p95\":16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":20,\"wall_ms_p95\":60,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (7.06 / 7.03 / 7.06 MiB); on the 100-call session BenchmarkRecap/100 reads; expected per archive 85.4.1: 12 / 16 MiB, flat in session size\"},{\"id\":\"recap.pending\",\"events\":[\"UserPromptSubmit\"],\"command\":[\"recap\",\"pending\"],\"payload_class\":\"prompt\",\"payload_bytes\":256,\"benchmark\":\"BenchmarkRecapPending/10\",\"alloc_bytes_max\":261190,\"maxrss_mib_p50\":6.06,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":8,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.09 / 6.06 / 5.98 MiB); on the 10-call session BenchmarkRecapPending/10 reads (the earlier 5.2 measured the no-store early return); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"probe.start\",\"events\":[\"SessionStart\"],\"command\":[\"probe\",\"start\"],\"payload_class\":\"session_start\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.97,\"maxrss_mib_p95\":24,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":400,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.02 / 5.97 / 5.91 MiB); nothing queued; expected per archive 85.4.1: 8 MiB with nothing queued at p50, mining and briefing at p95\"},{\"id\":\"probe.end\",\"events\":[\"SessionEnd\"],\"command\":[\"probe\",\"end\"],\"payload_class\":\"session_end\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.98,\"maxrss_mib_p95\":6.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":6,\"wall_ms_p95\":12,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.95 / 6.05 / 5.98 MiB); closing a started session; expected per archive 85.4.1: one record, 6 / 6.5 MiB\"}],\"rss_cases\":[{\"id\":\"hook.400B\",\"entry\":\"hook\",\"payload_bytes\":400,\"budget_mib\":6.5008,\"measured_mib\":5.97,\"gates_from\":\"P-08\"},{\"id\":\"hook.40KB\",\"entry\":\"hook\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.12,\"gates_from\":\"P-08\"},{\"id\":\"hook.1MiB\",\"entry\":\"hook\",\"payload_bytes\":1048576,\"budget_mib\":9.9,\"measured_mib\":9.48,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.4MiB\",\"entry\":\"hook\",\"payload_bytes\":4194304,\"budget_mib\":20.1,\"measured_mib\":19.72,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.8MiB\",\"entry\":\"hook\",\"payload_bytes\":8388608,\"budget_mib\":33.7,\"measured_mib\":31.88,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.540B\",\"entry\":\"post\",\"payload_bytes\":540,\"budget_mib\":6.5011,\"measured_mib\":6.06,\"gates_from\":\"P-08\"},{\"id\":\"post.40KB\",\"entry\":\"post\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.05,\"gates_from\":\"P-08\"},{\"id\":\"post.1MiB\",\"entry\":\"post\",\"payload_bytes\":1048576,\"budget_mib\":8.95,\"measured_mib\":8.86,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.4MiB\",\"entry\":\"post\",\"payload_bytes\":4194304,\"budget_mib\":16.3,\"measured_mib\":15.42,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.8MiB\",\"entry\":\"post\",\"payload_bytes\":8388608,\"budget_mib\":26.1,\"measured_mib\":23.66,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"}],\"per_tool_call\":{\"memory_time_mib_ms_max\":379,\"summed_peak_mib_max\":32,\"process_creations_max\":{\"settings\":6,\"plugin\":6}},\"parallel\":{\"ways\":64,\"summed_peak_mib_max\":704,\"basis\":\"per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured\"},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":2656,\"measured_allocs_per_op\":33,\"bytes_per_op_max\":2656,\"allocs_per_op_max\":33},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":268944,\"measured_allocs_per_op\":229,\"bytes_per_op_max\":268944,\"allocs_per_op_max\":229},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":17838,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":17838,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1886832,\"measured_allocs_per_op\":6828,\"bytes_per_op_max\":1886832,\"allocs_per_op_max\":6828},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8422345,\"measured_allocs_per_op\":31708,\"bytes_per_op_max\":8422345,\"allocs_per_op_max\":31708},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36024352,\"measured_allocs_per_op\":121991,\"bytes_per_op_max\":36024352,\"allocs_per_op_max\":121991},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235467679,\"measured_allocs_per_op\":696819,\"bytes_per_op_max\":235467679,\"allocs_per_op_max\":696819},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":54058,\"measured_allocs_per_op\":362,\"bytes_per_op_max\":54058,\"allocs_per_op_max\":362},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":218378,\"measured_allocs_per_op\":310,\"bytes_per_op_max\":218378,\"allocs_per_op_max\":310},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":46893,\"measured_allocs_per_op\":290,\"bytes_per_op_max\":46893,\"allocs_per_op_max\":290},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":892802,\"measured_allocs_per_op\":295,\"bytes_per_op_max\":892802,\"allocs_per_op_max\":295},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":263952,\"measured_allocs_per_op\":558,\"bytes_per_op_max\":263952,\"allocs_per_op_max\":558},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1075559,\"measured_allocs_per_op\":3849,\"bytes_per_op_max\":1075559,\"allocs_per_op_max\":3849},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9513616,\"measured_allocs_per_op\":35359,\"bytes_per_op_max\":9513616,\"allocs_per_op_max\":35359},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":261190,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261190,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9511308,\"measured_allocs_per_op\":35356,\"bytes_per_op_max\":9511308,\"allocs_per_op_max\":35356},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":15778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":15778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":16426,\"measured_allocs_per_op\":83,\"bytes_per_op_max\":16426,\"allocs_per_op_max\":83},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":483310,\"measured_allocs_per_op\":3142,\"bytes_per_op_max\":483310,\"allocs_per_op_max\":3142},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10646,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10646,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":11351,\"measured_allocs_per_op\":51,\"bytes_per_op_max\":11351,\"allocs_per_op_max\":51},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":478744,\"measured_allocs_per_op\":2949,\"bytes_per_op_max\":478744,\"allocs_per_op_max\":2949},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":499297,\"measured_allocs_per_op\":3138,\"bytes_per_op_max\":499297,\"allocs_per_op_max\":3138},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163589,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163589,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459141,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459141,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}],\"growth\":[],\"disk\":{\"bytes_per_call_max\":6000}}" diff --git a/test/perf/budgets.json b/test/perf/budgets.json index 6370eb9f..d87d0dbf 100644 --- a/test/perf/budgets.json +++ b/test/perf/budgets.json @@ -30,7 +30,7 @@ "parallel": {"ways": 64, "summed_peak_mib_max": 704, "basis": "per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured"}, "benchmarks": [ {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 2656, "measured_allocs_per_op": 33, "bytes_per_op_max": 2656, "allocs_per_op_max": 33}, - {"name": "BenchmarkDerive/bash_long_4k", "measured_bytes_per_op": 285328, "measured_allocs_per_op": 229, "bytes_per_op_max": 285328, "allocs_per_op_max": 229}, + {"name": "BenchmarkDerive/bash_long_4k", "measured_bytes_per_op": 268944, "measured_allocs_per_op": 229, "bytes_per_op_max": 268944, "allocs_per_op_max": 229}, {"name": "BenchmarkDerive/edit_50k", "measured_bytes_per_op": 63464, "measured_allocs_per_op": 28, "bytes_per_op_max": 63464, "allocs_per_op_max": 28}, {"name": "BenchmarkLexTokens", "measured_bytes_per_op": 128544, "measured_allocs_per_op": 750, "bytes_per_op_max": 128544, "allocs_per_op_max": 750}, {"name": "BenchmarkRecordBytesPerCall", "measured_bytes_per_op": 17838, "measured_allocs_per_op": 95, "bytes_per_op_max": 17838, "allocs_per_op_max": 95}, From 675ec7e9fe3fa0076757083f4df72cfdae6bef99 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Thu, 22 Oct 2026 03:20:24 -0700 Subject: [PATCH 45/50] shape: restate the token bound from the go1.27.3 row The previous commit restated BenchmarkDerive/bash_long_4k at 268,944 B/op, measured under go1.27.3 on this tree, and left bytesPerToken at 268, the figure from go1.27.2's 285,328. TestRES9_BoundMatchesItsArithmetic failed on it, as it is there to. 268,944 over the line's 1,068 tokens is 251.8, rounded up to 252, and maxTokens is 66,576 where it was 62,601. The doc's per-reading figure follows BenchmarkLexTokens' 99,184 B/op (93 a token), and the RES-10 mutant note that quotes the bound follows it too. --- internal/shape/tokenize.go | 10 +++++----- test/mutation/mutants/RES-10_leading-cd.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/shape/tokenize.go b/internal/shape/tokenize.go index e900fe88..67b85985 100644 --- a/internal/shape/tokenize.go +++ b/internal/shape/tokenize.go @@ -32,20 +32,20 @@ var errTokenBound = errors.New("token bound") // of one token. That cost is the whole Derive's, not the lexer's: Derive reads // a line three times (tokenizeShape for argc, tokenizeProgram for the program // and the leading-cd count, tokenizeList for the masked status), so a token -// costs about 268 bytes where one reading of it costs about 101 +// costs about 252 bytes where one reading of it costs about 93 // (BenchmarkLexTokens). The hook's LeadingDirectory reads the line once more, -// outside Derive. bytesPerToken is BenchmarkDerive/bash_long_4k's 285,328 +// outside Derive. bytesPerToken is BenchmarkDerive/bash_long_4k's 268,944 // B/op over that line's 1,068 tokens (test/perf/budgets.json, measured -// 2026-10-13 under go1.27.2), 267.2, rounded up; it was 395 while a word's +// 2026-10-22 under go1.27.3), 251.8, rounded up; it was 395 while a word's // text was copied byte by byte and the leading-cd count read the line a // fourth time. TestRES9_BoundMatchesItsArithmetic holds the constant to that // row, so a change that moves Derive's cost per token restates the bound -// with the row. The bound is 62,601 tokens: a script handed to an +// with the row. The bound is 66,576 tokens: a script handed to an // interpreter as a here-document, which the program search reads word by // word, runs to a few thousand. const ( shapeBudget = 16 << 20 - bytesPerToken = 268 + bytesPerToken = 252 maxTokens = shapeBudget / bytesPerToken ) diff --git a/test/mutation/mutants/RES-10_leading-cd.py b/test/mutation/mutants/RES-10_leading-cd.py index e6045510..9669cd41 100644 --- a/test/mutation/mutants/RES-10_leading-cd.py +++ b/test/mutation/mutants/RES-10_leading-cd.py @@ -25,7 +25,7 @@ # own allocation, in package hook, which is what lets TestRES10_LeadingCdFoldIsBounded # gate on TotalAlloc instead of wall time (85.3.10 is not a clock this test # can hold an uninstrumented build to, let alone a -race one). 20000 still -# compiles (maxTokens is 62601 today; 3*(20000+1) = 60003 clears it, where +# compiles (maxTokens is 66576 today; 3*(20000+1) = 60003 clears it, where # 3*(129) does not clear it at the old spec's 41,700 estimate -- this is why # the sibling mutant above stops at 128), and it is far past the 14,000 cds # TestRES10_LeadingCdFoldIsBounded's "14,000 steps, under the token bound" From 71eb1fd593038ae6fa1fbc318dad4082d2fea603 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Thu, 22 Oct 2026 03:20:24 -0700 Subject: [PATCH 46/50] cmd/rashomon: RES-29's git@ row is recorded once with record_oversize The git@ scheme runs line is one 8 MiB word, so it is the command's program, and a program over store.MaxProgramBytes is left out of the declaration and said on the coverage record (REC-52, from main). The row expected verified coverage, written before that cap; its run is recorded once and is unverified for record_oversize and no other reason, which the test now holds it to. Every other row is still verified. --- cmd/rashomon/res29_test.go | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/cmd/rashomon/res29_test.go b/cmd/rashomon/res29_test.go index 5fe81ec8..a4f431b4 100644 --- a/cmd/rashomon/res29_test.go +++ b/cmd/rashomon/res29_test.go @@ -89,20 +89,26 @@ func TestRES29_PathologicalInputsStayBounded(t *testing.T) { if runtime.GOOS == "darwin" { rssMiB = 76 } + // withheld marks the row whose record holds a value over its store cap: + // the git@ line is one 8 MiB word, which is its program, and a program + // over store.MaxProgramBytes is left out of the declaration and said on + // the coverage record (REC-52). That run is recorded once and is not + // verified, for that reason and no other. type row struct { - name string - payload func(*testing.T) []byte + name string + payload func(*testing.T) []byte + withheld bool } var rows []row for _, l := range pathological.Lines() { rows = append(rows, row{"8 MiB of " + l.Name, func(t *testing.T) []byte { return res29Payload(t, 8<<20, l.Prefix, l.Unit, l.Suffix) - }}) + }, l.Name == "git@ scheme runs"}) } nested := `cd "` + strings.Repeat(`$(echo "`, 17) + "x" + strings.Repeat(`")`, 17) + `" && ls` rows = append(rows, - row{"a 1 MB cd a&&", func(t *testing.T) []byte { return res29Payload(t, 1_000_000, "", "cd a&&", "") }}, - row{"nesting one past maxNest", func(t *testing.T) []byte { return res29Payload(t, len(nested)+1024, nested, " ", "") }}, + row{"a 1 MB cd a&&", func(t *testing.T) []byte { return res29Payload(t, 1_000_000, "", "cd a&&", "") }, false}, + row{"nesting one past maxNest", func(t *testing.T) []byte { return res29Payload(t, len(nested)+1024, nested, " ", "") }, false}, ) if rows[0].name != "8 MiB of short words" || rows[7].name != "8 MiB of redirections" { t.Fatalf("premise: the table's first row is %q and its eighth %q; RES-29 names short words and `<`", rows[0].name, rows[7].name) @@ -174,8 +180,21 @@ func TestRES29_PathologicalInputsStayBounded(t *testing.T) { if err != nil { t.Fatal(err) } - if got := benchCounts(run); got != [5]int{1, 1, 0, 1, 0} { - t.Errorf("store holds %v declarations, terminals, executions, verified coverage and skipped lines; want the call recorded once", got) + want := [5]int{1, 1, 0, 1, 0} + if tc.withheld { + want[3] = 0 + var reasons []string + for _, c := range run.Coverage { + if c.Reason != nil { + reasons = append(reasons, *c.Reason) + } + } + if len(reasons) != 1 || reasons[0] != store.ReasonRecordOversize { + t.Errorf("coverage reasons %q; want only %s", reasons, store.ReasonRecordOversize) + } + } + if got := benchCounts(run); got != want { + t.Errorf("store holds %v declarations, terminals, executions, verified coverage and skipped lines; want %v, the call recorded once", got, want) } }) } From 47b22171e2d967bf69b9ba22c41dbcdd62068623 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Thu, 22 Oct 2026 03:45:08 -0700 Subject: [PATCH 47/50] perf: measure shape-bounds' rows on main after hook-install hook-install landed on main with its own rows, so this branch's rows are measured again on the tree it now is (best of three, -benchtime 20x, go1.27.3, Apple M3). Twenty-two move; the hook and post benchmarks fall with the shape work, BenchmarkRunHook/bash_short to 50,534 B and 301 allocs from main's 54,058 B and 362, and BenchmarkDerive/bash_long_4k is 268,944 B as before, so the token bound stands at 252 bytes a token. The hook and post entries' alloc_bytes_max follow their benchmarks, the DEL-7 and RES-36 anchors follow the rows, and internal/budget/budgets_gen.go is regenerated from the file. --- internal/budget/budgets_gen.go | 2 +- test/mutation/mutants/DEL-7_budget-rows.py | 2 +- .../mutants/RES-36_entry-alloc-rows.py | 2 +- test/perf/budgets.json | 56 +++++++++---------- 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/internal/budget/budgets_gen.go b/internal/budget/budgets_gen.go index 68f738c6..61168f05 100644 --- a/internal/budget/budgets_gen.go +++ b/internal/budget/budgets_gen.go @@ -3,4 +3,4 @@ package budget // tableJSON is test/perf/budgets.json, compacted; Table decodes it on first use. -const tableJSON = "{\"budgets_version\":1,\"go_version\":\"go1.27.3\",\"benchmark_rows\":\"the measured best of three on the reference machine (darwin/arm64, go1.27.3, -benchtime 20x, 2026-10-22); a row's max equals its measurement, and the gate's ten percent (scripts/benchgate) is the only margin\",\"benchmark_platform\":\"darwin/arm64\",\"platforms\":{\"darwin/arm64\":{\"reference\":\"Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \\\"-s -w\\\")\",\"set_from\":\"measured 2026-10-13 on the P-06 tree (archive 85.1.2)\",\"tolerance\":0.15,\"tolerance_until\":\"2026-11-06\",\"tolerance_after\":0.1,\"entries\":{},\"case_budgets\":{}},\"linux/amd64\":{\"reference\":\"ubuntu-latest\",\"set_from\":\"measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on d2071b6 by the measure-linux-benchmarks job, run 39762603829 (best of three, -benchtime 20x)\",\"tolerance\":0.1,\"tolerance_until\":\"\",\"tolerance_after\":0.1,\"entries\":{\"hook.bash_short\":{\"p50\":7.71,\"p95\":7.71},\"post.bash_short\":{\"p50\":7.65,\"p95\":7.65},\"hook.edit_50k\":{\"p50\":9.67,\"p95\":9.67},\"post.edit_314k\":{\"p50\":7.83,\"p95\":7.83},\"recap.stop\":{\"p50\":8.07,\"p95\":8.07},\"recap.pending\":{\"p50\":7.93,\"p95\":7.93},\"probe.start\":{\"p50\":7.64,\"p95\":7.64},\"probe.end\":{\"p50\":7.66,\"p95\":7.66}},\"case_budgets\":{\"hook.400B\":7.64,\"hook.40KB\":7.7,\"post.540B\":7.65,\"post.40KB\":7.65},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":4624,\"measured_allocs_per_op\":71,\"bytes_per_op_max\":4624,\"allocs_per_op_max\":71},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":430689,\"measured_allocs_per_op\":2333,\"bytes_per_op_max\":430689,\"allocs_per_op_max\":2333},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":16550,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":16550,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1885613,\"measured_allocs_per_op\":6829,\"bytes_per_op_max\":1885613,\"allocs_per_op_max\":6829},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8417557,\"measured_allocs_per_op\":31710,\"bytes_per_op_max\":8417557,\"allocs_per_op_max\":31710},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36038866,\"measured_allocs_per_op\":122027,\"bytes_per_op_max\":36038866,\"allocs_per_op_max\":122027},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235463295,\"measured_allocs_per_op\":696851,\"bytes_per_op_max\":235463295,\"allocs_per_op_max\":696851},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":50194,\"measured_allocs_per_op\":336,\"bytes_per_op_max\":50194,\"allocs_per_op_max\":336},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":213832,\"measured_allocs_per_op\":282,\"bytes_per_op_max\":213832,\"allocs_per_op_max\":282},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":43419,\"measured_allocs_per_op\":263,\"bytes_per_op_max\":43419,\"allocs_per_op_max\":263},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":885698,\"measured_allocs_per_op\":262,\"bytes_per_op_max\":885698,\"allocs_per_op_max\":262},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":261837,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261837,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1072366,\"measured_allocs_per_op\":3840,\"bytes_per_op_max\":1072366,\"allocs_per_op_max\":3840},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9506120,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9506120,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":260018,\"measured_allocs_per_op\":556,\"bytes_per_op_max\":260018,\"allocs_per_op_max\":556},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9503936,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9503936,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":14273,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14273,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":14778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":432603,\"measured_allocs_per_op\":2948,\"bytes_per_op_max\":432603,\"allocs_per_op_max\":2948},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10061,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10061,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":10407,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":10407,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":440815,\"measured_allocs_per_op\":2946,\"bytes_per_op_max\":440815,\"allocs_per_op_max\":2946},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":440657,\"measured_allocs_per_op\":2945,\"bytes_per_op_max\":440657,\"allocs_per_op_max\":2945},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163584,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163584,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}]}},\"entries\":[{\"id\":\"hook.bash_short\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"bash_short\",\"payload_bytes\":400,\"benchmark\":\"BenchmarkRunHook/bash_short\",\"alloc_bytes_max\":54058,\"maxrss_mib_p50\":5.95,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":10,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths\"},{\"id\":\"hook.edit_50k\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"edit_50k\",\"payload_bytes\":51200,\"benchmark\":\"BenchmarkRunHook/edit_50k\",\"alloc_bytes_max\":218378,\"maxrss_mib_p50\":6.20,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":25,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB\"},{\"id\":\"post.bash_short\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"bash_short\",\"payload_bytes\":540,\"benchmark\":\"BenchmarkRunPost/bash_short\",\"alloc_bytes_max\":46893,\"maxrss_mib_p50\":5.94,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":11,\"wall_ms_p95\":18,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.94 / 5.89 / 6.00 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"post.edit_314k\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"edit_314k\",\"payload_bytes\":314000,\"benchmark\":\"BenchmarkRunPost/edit_314k\",\"alloc_bytes_max\":892802,\"maxrss_mib_p50\":6.73,\"maxrss_mib_p95\":7.16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":12,\"wall_ms_p95\":22,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.73 / 6.67 / 6.73 MiB); p95 is RES-28's line at 314 KB (7.16 MiB), which the archive applies from P-10 in place of 8 / 10 MiB; 60 runs gave p95 6.92, max 6.95\"},{\"id\":\"recap.stop\",\"events\":[\"Stop\",\"StopFailure\"],\"command\":[\"recap\"],\"payload_class\":\"stop_100\",\"payload_bytes\":2048,\"benchmark\":\"BenchmarkRecap/100\",\"alloc_bytes_max\":1075559,\"maxrss_mib_p50\":7.06,\"maxrss_mib_p95\":16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":20,\"wall_ms_p95\":60,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (7.06 / 7.03 / 7.06 MiB); on the 100-call session BenchmarkRecap/100 reads; expected per archive 85.4.1: 12 / 16 MiB, flat in session size\"},{\"id\":\"recap.pending\",\"events\":[\"UserPromptSubmit\"],\"command\":[\"recap\",\"pending\"],\"payload_class\":\"prompt\",\"payload_bytes\":256,\"benchmark\":\"BenchmarkRecapPending/10\",\"alloc_bytes_max\":261190,\"maxrss_mib_p50\":6.06,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":8,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.09 / 6.06 / 5.98 MiB); on the 10-call session BenchmarkRecapPending/10 reads (the earlier 5.2 measured the no-store early return); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"probe.start\",\"events\":[\"SessionStart\"],\"command\":[\"probe\",\"start\"],\"payload_class\":\"session_start\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.97,\"maxrss_mib_p95\":24,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":400,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.02 / 5.97 / 5.91 MiB); nothing queued; expected per archive 85.4.1: 8 MiB with nothing queued at p50, mining and briefing at p95\"},{\"id\":\"probe.end\",\"events\":[\"SessionEnd\"],\"command\":[\"probe\",\"end\"],\"payload_class\":\"session_end\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.98,\"maxrss_mib_p95\":6.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":6,\"wall_ms_p95\":12,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.95 / 6.05 / 5.98 MiB); closing a started session; expected per archive 85.4.1: one record, 6 / 6.5 MiB\"}],\"rss_cases\":[{\"id\":\"hook.400B\",\"entry\":\"hook\",\"payload_bytes\":400,\"budget_mib\":6.5008,\"measured_mib\":5.97,\"gates_from\":\"P-08\"},{\"id\":\"hook.40KB\",\"entry\":\"hook\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.12,\"gates_from\":\"P-08\"},{\"id\":\"hook.1MiB\",\"entry\":\"hook\",\"payload_bytes\":1048576,\"budget_mib\":9.9,\"measured_mib\":9.48,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.4MiB\",\"entry\":\"hook\",\"payload_bytes\":4194304,\"budget_mib\":20.1,\"measured_mib\":19.72,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.8MiB\",\"entry\":\"hook\",\"payload_bytes\":8388608,\"budget_mib\":33.7,\"measured_mib\":31.88,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.540B\",\"entry\":\"post\",\"payload_bytes\":540,\"budget_mib\":6.5011,\"measured_mib\":6.06,\"gates_from\":\"P-08\"},{\"id\":\"post.40KB\",\"entry\":\"post\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.05,\"gates_from\":\"P-08\"},{\"id\":\"post.1MiB\",\"entry\":\"post\",\"payload_bytes\":1048576,\"budget_mib\":8.95,\"measured_mib\":8.86,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.4MiB\",\"entry\":\"post\",\"payload_bytes\":4194304,\"budget_mib\":16.3,\"measured_mib\":15.42,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.8MiB\",\"entry\":\"post\",\"payload_bytes\":8388608,\"budget_mib\":26.1,\"measured_mib\":23.66,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"}],\"per_tool_call\":{\"memory_time_mib_ms_max\":379,\"summed_peak_mib_max\":32,\"process_creations_max\":{\"settings\":6,\"plugin\":6}},\"parallel\":{\"ways\":64,\"summed_peak_mib_max\":704,\"basis\":\"per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured\"},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":2656,\"measured_allocs_per_op\":33,\"bytes_per_op_max\":2656,\"allocs_per_op_max\":33},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":268944,\"measured_allocs_per_op\":229,\"bytes_per_op_max\":268944,\"allocs_per_op_max\":229},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":17838,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":17838,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1886832,\"measured_allocs_per_op\":6828,\"bytes_per_op_max\":1886832,\"allocs_per_op_max\":6828},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8422345,\"measured_allocs_per_op\":31708,\"bytes_per_op_max\":8422345,\"allocs_per_op_max\":31708},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36024352,\"measured_allocs_per_op\":121991,\"bytes_per_op_max\":36024352,\"allocs_per_op_max\":121991},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235467679,\"measured_allocs_per_op\":696819,\"bytes_per_op_max\":235467679,\"allocs_per_op_max\":696819},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":54058,\"measured_allocs_per_op\":362,\"bytes_per_op_max\":54058,\"allocs_per_op_max\":362},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":218378,\"measured_allocs_per_op\":310,\"bytes_per_op_max\":218378,\"allocs_per_op_max\":310},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":46893,\"measured_allocs_per_op\":290,\"bytes_per_op_max\":46893,\"allocs_per_op_max\":290},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":892802,\"measured_allocs_per_op\":295,\"bytes_per_op_max\":892802,\"allocs_per_op_max\":295},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":263952,\"measured_allocs_per_op\":558,\"bytes_per_op_max\":263952,\"allocs_per_op_max\":558},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1075559,\"measured_allocs_per_op\":3849,\"bytes_per_op_max\":1075559,\"allocs_per_op_max\":3849},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9513616,\"measured_allocs_per_op\":35359,\"bytes_per_op_max\":9513616,\"allocs_per_op_max\":35359},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":261190,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261190,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9511308,\"measured_allocs_per_op\":35356,\"bytes_per_op_max\":9511308,\"allocs_per_op_max\":35356},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":15778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":15778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":16426,\"measured_allocs_per_op\":83,\"bytes_per_op_max\":16426,\"allocs_per_op_max\":83},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":483310,\"measured_allocs_per_op\":3142,\"bytes_per_op_max\":483310,\"allocs_per_op_max\":3142},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10646,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10646,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":11351,\"measured_allocs_per_op\":51,\"bytes_per_op_max\":11351,\"allocs_per_op_max\":51},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":478744,\"measured_allocs_per_op\":2949,\"bytes_per_op_max\":478744,\"allocs_per_op_max\":2949},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":499297,\"measured_allocs_per_op\":3138,\"bytes_per_op_max\":499297,\"allocs_per_op_max\":3138},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163589,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163589,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459141,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459141,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}],\"growth\":[],\"disk\":{\"bytes_per_call_max\":6000}}" +const tableJSON = "{\"budgets_version\":1,\"go_version\":\"go1.27.3\",\"benchmark_rows\":\"the measured best of three on the reference machine (darwin/arm64, go1.27.3, -benchtime 20x, 2026-10-22); a row's max equals its measurement, and the gate's ten percent (scripts/benchgate) is the only margin\",\"benchmark_platform\":\"darwin/arm64\",\"platforms\":{\"darwin/arm64\":{\"reference\":\"Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \\\"-s -w\\\")\",\"set_from\":\"measured 2026-10-13 on the P-06 tree (archive 85.1.2)\",\"tolerance\":0.15,\"tolerance_until\":\"2026-11-06\",\"tolerance_after\":0.1,\"entries\":{},\"case_budgets\":{}},\"linux/amd64\":{\"reference\":\"ubuntu-latest\",\"set_from\":\"measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on d2071b6 by the measure-linux-benchmarks job, run 39762603829 (best of three, -benchtime 20x)\",\"tolerance\":0.1,\"tolerance_until\":\"\",\"tolerance_after\":0.1,\"entries\":{\"hook.bash_short\":{\"p50\":7.71,\"p95\":7.71},\"post.bash_short\":{\"p50\":7.65,\"p95\":7.65},\"hook.edit_50k\":{\"p50\":9.67,\"p95\":9.67},\"post.edit_314k\":{\"p50\":7.83,\"p95\":7.83},\"recap.stop\":{\"p50\":8.07,\"p95\":8.07},\"recap.pending\":{\"p50\":7.93,\"p95\":7.93},\"probe.start\":{\"p50\":7.64,\"p95\":7.64},\"probe.end\":{\"p50\":7.66,\"p95\":7.66}},\"case_budgets\":{\"hook.400B\":7.64,\"hook.40KB\":7.7,\"post.540B\":7.65,\"post.40KB\":7.65},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":4624,\"measured_allocs_per_op\":71,\"bytes_per_op_max\":4624,\"allocs_per_op_max\":71},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":430689,\"measured_allocs_per_op\":2333,\"bytes_per_op_max\":430689,\"allocs_per_op_max\":2333},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":16550,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":16550,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1885613,\"measured_allocs_per_op\":6829,\"bytes_per_op_max\":1885613,\"allocs_per_op_max\":6829},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8417557,\"measured_allocs_per_op\":31710,\"bytes_per_op_max\":8417557,\"allocs_per_op_max\":31710},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36038866,\"measured_allocs_per_op\":122027,\"bytes_per_op_max\":36038866,\"allocs_per_op_max\":122027},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235463295,\"measured_allocs_per_op\":696851,\"bytes_per_op_max\":235463295,\"allocs_per_op_max\":696851},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":50194,\"measured_allocs_per_op\":336,\"bytes_per_op_max\":50194,\"allocs_per_op_max\":336},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":213832,\"measured_allocs_per_op\":282,\"bytes_per_op_max\":213832,\"allocs_per_op_max\":282},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":43419,\"measured_allocs_per_op\":263,\"bytes_per_op_max\":43419,\"allocs_per_op_max\":263},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":885698,\"measured_allocs_per_op\":262,\"bytes_per_op_max\":885698,\"allocs_per_op_max\":262},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":261837,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261837,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1072366,\"measured_allocs_per_op\":3840,\"bytes_per_op_max\":1072366,\"allocs_per_op_max\":3840},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9506120,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9506120,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":260018,\"measured_allocs_per_op\":556,\"bytes_per_op_max\":260018,\"allocs_per_op_max\":556},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9503936,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9503936,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":14273,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14273,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":14778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":432603,\"measured_allocs_per_op\":2948,\"bytes_per_op_max\":432603,\"allocs_per_op_max\":2948},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10061,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10061,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":10407,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":10407,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":440815,\"measured_allocs_per_op\":2946,\"bytes_per_op_max\":440815,\"allocs_per_op_max\":2946},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":440657,\"measured_allocs_per_op\":2945,\"bytes_per_op_max\":440657,\"allocs_per_op_max\":2945},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163584,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163584,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}]}},\"entries\":[{\"id\":\"hook.bash_short\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"bash_short\",\"payload_bytes\":400,\"benchmark\":\"BenchmarkRunHook/bash_short\",\"alloc_bytes_max\":50534,\"maxrss_mib_p50\":5.95,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":10,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths\"},{\"id\":\"hook.edit_50k\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"edit_50k\",\"payload_bytes\":51200,\"benchmark\":\"BenchmarkRunHook/edit_50k\",\"alloc_bytes_max\":218256,\"maxrss_mib_p50\":6.20,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":25,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB\"},{\"id\":\"post.bash_short\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"bash_short\",\"payload_bytes\":540,\"benchmark\":\"BenchmarkRunPost/bash_short\",\"alloc_bytes_max\":45146,\"maxrss_mib_p50\":5.94,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":11,\"wall_ms_p95\":18,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.94 / 5.89 / 6.00 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"post.edit_314k\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"edit_314k\",\"payload_bytes\":314000,\"benchmark\":\"BenchmarkRunPost/edit_314k\",\"alloc_bytes_max\":891098,\"maxrss_mib_p50\":6.73,\"maxrss_mib_p95\":7.16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":12,\"wall_ms_p95\":22,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.73 / 6.67 / 6.73 MiB); p95 is RES-28's line at 314 KB (7.16 MiB), which the archive applies from P-10 in place of 8 / 10 MiB; 60 runs gave p95 6.92, max 6.95\"},{\"id\":\"recap.stop\",\"events\":[\"Stop\",\"StopFailure\"],\"command\":[\"recap\"],\"payload_class\":\"stop_100\",\"payload_bytes\":2048,\"benchmark\":\"BenchmarkRecap/100\",\"alloc_bytes_max\":1074929,\"maxrss_mib_p50\":7.06,\"maxrss_mib_p95\":16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":20,\"wall_ms_p95\":60,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (7.06 / 7.03 / 7.06 MiB); on the 100-call session BenchmarkRecap/100 reads; expected per archive 85.4.1: 12 / 16 MiB, flat in session size\"},{\"id\":\"recap.pending\",\"events\":[\"UserPromptSubmit\"],\"command\":[\"recap\",\"pending\"],\"payload_class\":\"prompt\",\"payload_bytes\":256,\"benchmark\":\"BenchmarkRecapPending/10\",\"alloc_bytes_max\":261660,\"maxrss_mib_p50\":6.06,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":8,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.09 / 6.06 / 5.98 MiB); on the 10-call session BenchmarkRecapPending/10 reads (the earlier 5.2 measured the no-store early return); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"probe.start\",\"events\":[\"SessionStart\"],\"command\":[\"probe\",\"start\"],\"payload_class\":\"session_start\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.97,\"maxrss_mib_p95\":24,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":400,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.02 / 5.97 / 5.91 MiB); nothing queued; expected per archive 85.4.1: 8 MiB with nothing queued at p50, mining and briefing at p95\"},{\"id\":\"probe.end\",\"events\":[\"SessionEnd\"],\"command\":[\"probe\",\"end\"],\"payload_class\":\"session_end\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.98,\"maxrss_mib_p95\":6.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":6,\"wall_ms_p95\":12,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.95 / 6.05 / 5.98 MiB); closing a started session; expected per archive 85.4.1: one record, 6 / 6.5 MiB\"}],\"rss_cases\":[{\"id\":\"hook.400B\",\"entry\":\"hook\",\"payload_bytes\":400,\"budget_mib\":6.5008,\"measured_mib\":5.97,\"gates_from\":\"P-08\"},{\"id\":\"hook.40KB\",\"entry\":\"hook\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.12,\"gates_from\":\"P-08\"},{\"id\":\"hook.1MiB\",\"entry\":\"hook\",\"payload_bytes\":1048576,\"budget_mib\":9.9,\"measured_mib\":9.48,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.4MiB\",\"entry\":\"hook\",\"payload_bytes\":4194304,\"budget_mib\":20.1,\"measured_mib\":19.72,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.8MiB\",\"entry\":\"hook\",\"payload_bytes\":8388608,\"budget_mib\":33.7,\"measured_mib\":31.88,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.540B\",\"entry\":\"post\",\"payload_bytes\":540,\"budget_mib\":6.5011,\"measured_mib\":6.06,\"gates_from\":\"P-08\"},{\"id\":\"post.40KB\",\"entry\":\"post\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.05,\"gates_from\":\"P-08\"},{\"id\":\"post.1MiB\",\"entry\":\"post\",\"payload_bytes\":1048576,\"budget_mib\":8.95,\"measured_mib\":8.86,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.4MiB\",\"entry\":\"post\",\"payload_bytes\":4194304,\"budget_mib\":16.3,\"measured_mib\":15.42,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.8MiB\",\"entry\":\"post\",\"payload_bytes\":8388608,\"budget_mib\":26.1,\"measured_mib\":23.66,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"}],\"per_tool_call\":{\"memory_time_mib_ms_max\":379,\"summed_peak_mib_max\":32,\"process_creations_max\":{\"settings\":6,\"plugin\":6}},\"parallel\":{\"ways\":64,\"summed_peak_mib_max\":704,\"basis\":\"per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured\"},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":2656,\"measured_allocs_per_op\":33,\"bytes_per_op_max\":2656,\"allocs_per_op_max\":33},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":268944,\"measured_allocs_per_op\":229,\"bytes_per_op_max\":268944,\"allocs_per_op_max\":229},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":99184,\"measured_allocs_per_op\":90,\"bytes_per_op_max\":99184,\"allocs_per_op_max\":90},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":17838,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":17838,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1887148,\"measured_allocs_per_op\":6829,\"bytes_per_op_max\":1887148,\"allocs_per_op_max\":6829},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8423210,\"measured_allocs_per_op\":31711,\"bytes_per_op_max\":8423210,\"allocs_per_op_max\":31711},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36007381,\"measured_allocs_per_op\":121982,\"bytes_per_op_max\":36007381,\"allocs_per_op_max\":121982},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235465389,\"measured_allocs_per_op\":696796,\"bytes_per_op_max\":235465389,\"allocs_per_op_max\":696796},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":50534,\"measured_allocs_per_op\":301,\"bytes_per_op_max\":50534,\"allocs_per_op_max\":301},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":218256,\"measured_allocs_per_op\":310,\"bytes_per_op_max\":218256,\"allocs_per_op_max\":310},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":45146,\"measured_allocs_per_op\":252,\"bytes_per_op_max\":45146,\"allocs_per_op_max\":252},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":891098,\"measured_allocs_per_op\":293,\"bytes_per_op_max\":891098,\"allocs_per_op_max\":293},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":263735,\"measured_allocs_per_op\":559,\"bytes_per_op_max\":263735,\"allocs_per_op_max\":559},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1074929,\"measured_allocs_per_op\":3844,\"bytes_per_op_max\":1074929,\"allocs_per_op_max\":3844},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9513196,\"measured_allocs_per_op\":35351,\"bytes_per_op_max\":9513196,\"allocs_per_op_max\":35351},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":261660,\"measured_allocs_per_op\":558,\"bytes_per_op_max\":261660,\"allocs_per_op_max\":558},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9511994,\"measured_allocs_per_op\":35359,\"bytes_per_op_max\":9511994,\"allocs_per_op_max\":35359},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":15434,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":15434,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":16114,\"measured_allocs_per_op\":83,\"bytes_per_op_max\":16114,\"allocs_per_op_max\":83},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":483825,\"measured_allocs_per_op\":3144,\"bytes_per_op_max\":483825,\"allocs_per_op_max\":3144},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10582,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10582,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":11006,\"measured_allocs_per_op\":50,\"bytes_per_op_max\":11006,\"allocs_per_op_max\":50},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":479550,\"measured_allocs_per_op\":2953,\"bytes_per_op_max\":479550,\"allocs_per_op_max\":2953},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":499403,\"measured_allocs_per_op\":3140,\"bytes_per_op_max\":499403,\"allocs_per_op_max\":3140},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163589,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163589,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}],\"growth\":[],\"disk\":{\"bytes_per_call_max\":6000}}" diff --git a/test/mutation/mutants/DEL-7_budget-rows.py b/test/mutation/mutants/DEL-7_budget-rows.py index a79ac3db..276e5b81 100644 --- a/test/mutation/mutants/DEL-7_budget-rows.py +++ b/test/mutation/mutants/DEL-7_budget-rows.py @@ -2,7 +2,7 @@ # of an entry is the whole regression; the test enumerates the installer's # events and the entries' floor classes. m("DEL-7 the hook floor row leaves budgets.json", "test/perf/budgets.json", - ' {"id": "hook.bash_short", "events": ["PreToolUse"], "command": ["hook"], "payload_class": "bash_short", "payload_bytes": 400, "benchmark": "BenchmarkRunHook/bash_short", "alloc_bytes_max": 54058, "maxrss_mib_p50": 5.95, "maxrss_mib_p95": 7.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 10, "wall_ms_p95": 20, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths"},\n', + ' {"id": "hook.bash_short", "events": ["PreToolUse"], "command": ["hook"], "payload_class": "bash_short", "payload_bytes": 400, "benchmark": "BenchmarkRunHook/bash_short", "alloc_bytes_max": 50534, "maxrss_mib_p50": 5.95, "maxrss_mib_p95": 7.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 10, "wall_ms_p95": 20, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths"},\n', '', "TestDEL7_") m("DEL-7 a probe entry no longer names its event", "test/perf/budgets.json", '"events": ["SessionEnd"]', '"events": []', "TestDEL7_") diff --git a/test/mutation/mutants/RES-36_entry-alloc-rows.py b/test/mutation/mutants/RES-36_entry-alloc-rows.py index 34a8729a..0a9a937e 100644 --- a/test/mutation/mutants/RES-36_entry-alloc-rows.py +++ b/test/mutation/mutants/RES-36_entry-alloc-rows.py @@ -2,6 +2,6 @@ # benchmark row it names. A figure above the measurement, or a benchmark # name with no row, leaves the entry's allocation budget unbacked. m("RES-36 an entry row's allocation budget drifts above its benchmark", "test/perf/budgets.json", - '"alloc_bytes_max": 54058,', '"alloc_bytes_max": 59463,', "TestRES36f_") + '"alloc_bytes_max": 50534,', '"alloc_bytes_max": 55587,', "TestRES36f_") m("RES-36 an entry row names a benchmark with no row", "test/perf/budgets.json", '"benchmark": "BenchmarkRecap/100",', '"benchmark": "BenchmarkRecap/200",', "TestRES36f_") diff --git a/test/perf/budgets.json b/test/perf/budgets.json index d87d0dbf..fca8e735 100644 --- a/test/perf/budgets.json +++ b/test/perf/budgets.json @@ -5,12 +5,12 @@ "benchmark_platform": "darwin/arm64", "platforms": {"darwin/arm64": {"reference": "Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \"-s -w\")", "set_from": "measured 2026-10-13 on the P-06 tree (archive 85.1.2)", "tolerance": 0.15, "tolerance_until": "2026-11-06", "tolerance_after": 0.1, "entries": {}, "case_budgets": {}}, "linux/amd64": {"reference": "ubuntu-latest", "set_from": "measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on d2071b6 by the measure-linux-benchmarks job, run 39762603829 (best of three, -benchtime 20x)", "tolerance": 0.1, "tolerance_until": "", "tolerance_after": 0.1, "entries": {"hook.bash_short": {"p50": 7.71, "p95": 7.71}, "post.bash_short": {"p50": 7.65, "p95": 7.65}, "hook.edit_50k": {"p50": 9.67, "p95": 9.67}, "post.edit_314k": {"p50": 7.83, "p95": 7.83}, "recap.stop": {"p50": 8.07, "p95": 8.07}, "recap.pending": {"p50": 7.93, "p95": 7.93}, "probe.start": {"p50": 7.64, "p95": 7.64}, "probe.end": {"p50": 7.66, "p95": 7.66}}, "case_budgets": {"hook.400B": 7.64, "hook.40KB": 7.7, "post.540B": 7.65, "post.40KB": 7.65}, "benchmarks": [{"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 4624, "measured_allocs_per_op": 71, "bytes_per_op_max": 4624, "allocs_per_op_max": 71}, {"name": "BenchmarkDerive/bash_long_4k", "measured_bytes_per_op": 430689, "measured_allocs_per_op": 2333, "bytes_per_op_max": 430689, "allocs_per_op_max": 2333}, {"name": "BenchmarkDerive/edit_50k", "measured_bytes_per_op": 63464, "measured_allocs_per_op": 28, "bytes_per_op_max": 63464, "allocs_per_op_max": 28}, {"name": "BenchmarkLexTokens", "measured_bytes_per_op": 128544, "measured_allocs_per_op": 750, "bytes_per_op_max": 128544, "allocs_per_op_max": 750}, {"name": "BenchmarkRecordBytesPerCall", "measured_bytes_per_op": 16550, "measured_allocs_per_op": 95, "bytes_per_op_max": 16550, "allocs_per_op_max": 95}, {"name": "BenchmarkBuild/188", "measured_bytes_per_op": 1885613, "measured_allocs_per_op": 6829, "bytes_per_op_max": 1885613, "allocs_per_op_max": 6829}, {"name": "BenchmarkBuild/899", "measured_bytes_per_op": 8417557, "measured_allocs_per_op": 31710, "bytes_per_op_max": 8417557, "allocs_per_op_max": 31710}, {"name": "BenchmarkBuild/3488", "measured_bytes_per_op": 36038866, "measured_allocs_per_op": 122027, "bytes_per_op_max": 36038866, "allocs_per_op_max": 122027}, {"name": "BenchmarkBuild/20000", "measured_bytes_per_op": 235463295, "measured_allocs_per_op": 696851, "bytes_per_op_max": 235463295, "allocs_per_op_max": 696851}, {"name": "BenchmarkRunHook/bash_short", "measured_bytes_per_op": 50194, "measured_allocs_per_op": 336, "bytes_per_op_max": 50194, "allocs_per_op_max": 336}, {"name": "BenchmarkRunHook/edit_50k", "measured_bytes_per_op": 213832, "measured_allocs_per_op": 282, "bytes_per_op_max": 213832, "allocs_per_op_max": 282}, {"name": "BenchmarkRunPost/bash_short", "measured_bytes_per_op": 43419, "measured_allocs_per_op": 263, "bytes_per_op_max": 43419, "allocs_per_op_max": 263}, {"name": "BenchmarkRunPost/edit_314k", "measured_bytes_per_op": 885698, "measured_allocs_per_op": 262, "bytes_per_op_max": 885698, "allocs_per_op_max": 262}, {"name": "BenchmarkRecap/10", "measured_bytes_per_op": 261837, "measured_allocs_per_op": 555, "bytes_per_op_max": 261837, "allocs_per_op_max": 555}, {"name": "BenchmarkRecap/100", "measured_bytes_per_op": 1072366, "measured_allocs_per_op": 3840, "bytes_per_op_max": 1072366, "allocs_per_op_max": 3840}, {"name": "BenchmarkRecap/1000", "measured_bytes_per_op": 9506120, "measured_allocs_per_op": 35345, "bytes_per_op_max": 9506120, "allocs_per_op_max": 35345}, {"name": "BenchmarkRecapPending/10", "measured_bytes_per_op": 260018, "measured_allocs_per_op": 556, "bytes_per_op_max": 260018, "allocs_per_op_max": 556}, {"name": "BenchmarkRecapPending/1000", "measured_bytes_per_op": 9503936, "measured_allocs_per_op": 35345, "bytes_per_op_max": 9503936, "allocs_per_op_max": 35345}, {"name": "BenchmarkHookStoreSequence/full", "measured_bytes_per_op": 14273, "measured_allocs_per_op": 79, "bytes_per_op_max": 14273, "allocs_per_op_max": 79}, {"name": "BenchmarkHookStoreSequence/deferred", "measured_bytes_per_op": 14778, "measured_allocs_per_op": 79, "bytes_per_op_max": 14778, "allocs_per_op_max": 79}, {"name": "BenchmarkAppendDeclarationParallel", "measured_bytes_per_op": 432603, "measured_allocs_per_op": 2948, "bytes_per_op_max": 432603, "allocs_per_op_max": 2948}, {"name": "BenchmarkOrderedAppendHold/serial_full", "measured_bytes_per_op": 10061, "measured_allocs_per_op": 47, "bytes_per_op_max": 10061, "allocs_per_op_max": 47}, {"name": "BenchmarkOrderedAppendHold/serial_deferred", "measured_bytes_per_op": 10407, "measured_allocs_per_op": 48, "bytes_per_op_max": 10407, "allocs_per_op_max": 48}, {"name": "BenchmarkOrderedAppendHold/parallel_full", "measured_bytes_per_op": 440815, "measured_allocs_per_op": 2946, "bytes_per_op_max": 440815, "allocs_per_op_max": 2946}, {"name": "BenchmarkOrderedAppendHold/parallel_deferred", "measured_bytes_per_op": 440657, "measured_allocs_per_op": 2945, "bytes_per_op_max": 440657, "allocs_per_op_max": 2945}, {"name": "BenchmarkTop", "measured_bytes_per_op": 616, "measured_allocs_per_op": 6, "bytes_per_op_max": 616, "allocs_per_op_max": 6}, {"name": "BenchmarkLines/4MiB", "measured_bytes_per_op": 24, "measured_allocs_per_op": 2, "bytes_per_op_max": 24, "allocs_per_op_max": 2}, {"name": "BenchmarkStream/64MiB_batch", "measured_bytes_per_op": 158321, "measured_allocs_per_op": 17, "bytes_per_op_max": 158321, "allocs_per_op_max": 17}, {"name": "BenchmarkStream/187MB_line", "measured_bytes_per_op": 349379, "measured_allocs_per_op": 30, "bytes_per_op_max": 349379, "allocs_per_op_max": 30}, {"name": "BenchmarkRead/400B", "measured_bytes_per_op": 4584, "measured_allocs_per_op": 4, "bytes_per_op_max": 4584, "allocs_per_op_max": 4}, {"name": "BenchmarkRead/40KB", "measured_bytes_per_op": 83456, "measured_allocs_per_op": 16, "bytes_per_op_max": 83456, "allocs_per_op_max": 16}, {"name": "BenchmarkRead/1MiB", "measured_bytes_per_op": 2163584, "measured_allocs_per_op": 48, "bytes_per_op_max": 2163584, "allocs_per_op_max": 48}, {"name": "BenchmarkRead/4MiB", "measured_bytes_per_op": 8459136, "measured_allocs_per_op": 97, "bytes_per_op_max": 8459136, "allocs_per_op_max": 97}, {"name": "BenchmarkRead/8MiB", "measured_bytes_per_op": 16823168, "measured_allocs_per_op": 161, "bytes_per_op_max": 16823168, "allocs_per_op_max": 161}]}}, "entries": [ - {"id": "hook.bash_short", "events": ["PreToolUse"], "command": ["hook"], "payload_class": "bash_short", "payload_bytes": 400, "benchmark": "BenchmarkRunHook/bash_short", "alloc_bytes_max": 54058, "maxrss_mib_p50": 5.95, "maxrss_mib_p95": 7.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 10, "wall_ms_p95": 20, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths"}, - {"id": "hook.edit_50k", "events": ["PreToolUse"], "command": ["hook"], "payload_class": "edit_50k", "payload_bytes": 51200, "benchmark": "BenchmarkRunHook/edit_50k", "alloc_bytes_max": 218378, "maxrss_mib_p50": 6.20, "maxrss_mib_p95": 7.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 15, "wall_ms_p95": 25, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB"}, - {"id": "post.bash_short", "events": ["PostToolUse", "PostToolUseFailure"], "command": ["post"], "payload_class": "bash_short", "payload_bytes": 540, "benchmark": "BenchmarkRunPost/bash_short", "alloc_bytes_max": 46893, "maxrss_mib_p50": 5.94, "maxrss_mib_p95": 7.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 11, "wall_ms_p95": 18, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.94 / 5.89 / 6.00 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB"}, - {"id": "post.edit_314k", "events": ["PostToolUse", "PostToolUseFailure"], "command": ["post"], "payload_class": "edit_314k", "payload_bytes": 314000, "benchmark": "BenchmarkRunPost/edit_314k", "alloc_bytes_max": 892802, "maxrss_mib_p50": 6.73, "maxrss_mib_p95": 7.16, "parent_maxrss_mib_p50": null, "wall_ms_p50": 12, "wall_ms_p95": 22, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.73 / 6.67 / 6.73 MiB); p95 is RES-28's line at 314 KB (7.16 MiB), which the archive applies from P-10 in place of 8 / 10 MiB; 60 runs gave p95 6.92, max 6.95"}, - {"id": "recap.stop", "events": ["Stop", "StopFailure"], "command": ["recap"], "payload_class": "stop_100", "payload_bytes": 2048, "benchmark": "BenchmarkRecap/100", "alloc_bytes_max": 1075559, "maxrss_mib_p50": 7.06, "maxrss_mib_p95": 16, "parent_maxrss_mib_p50": null, "wall_ms_p50": 20, "wall_ms_p95": 60, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (7.06 / 7.03 / 7.06 MiB); on the 100-call session BenchmarkRecap/100 reads; expected per archive 85.4.1: 12 / 16 MiB, flat in session size"}, - {"id": "recap.pending", "events": ["UserPromptSubmit"], "command": ["recap", "pending"], "payload_class": "prompt", "payload_bytes": 256, "benchmark": "BenchmarkRecapPending/10", "alloc_bytes_max": 261190, "maxrss_mib_p50": 6.06, "maxrss_mib_p95": 7.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 8, "wall_ms_p95": 20, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.09 / 6.06 / 5.98 MiB); on the 10-call session BenchmarkRecapPending/10 reads (the earlier 5.2 measured the no-store early return); expected per archive 85.4.1: 6.5 / 7.5 MiB"}, + {"id": "hook.bash_short", "events": ["PreToolUse"], "command": ["hook"], "payload_class": "bash_short", "payload_bytes": 400, "benchmark": "BenchmarkRunHook/bash_short", "alloc_bytes_max": 50534, "maxrss_mib_p50": 5.95, "maxrss_mib_p95": 7.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 10, "wall_ms_p95": 20, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths"}, + {"id": "hook.edit_50k", "events": ["PreToolUse"], "command": ["hook"], "payload_class": "edit_50k", "payload_bytes": 51200, "benchmark": "BenchmarkRunHook/edit_50k", "alloc_bytes_max": 218256, "maxrss_mib_p50": 6.20, "maxrss_mib_p95": 7.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 15, "wall_ms_p95": 25, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB"}, + {"id": "post.bash_short", "events": ["PostToolUse", "PostToolUseFailure"], "command": ["post"], "payload_class": "bash_short", "payload_bytes": 540, "benchmark": "BenchmarkRunPost/bash_short", "alloc_bytes_max": 45146, "maxrss_mib_p50": 5.94, "maxrss_mib_p95": 7.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 11, "wall_ms_p95": 18, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.94 / 5.89 / 6.00 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB"}, + {"id": "post.edit_314k", "events": ["PostToolUse", "PostToolUseFailure"], "command": ["post"], "payload_class": "edit_314k", "payload_bytes": 314000, "benchmark": "BenchmarkRunPost/edit_314k", "alloc_bytes_max": 891098, "maxrss_mib_p50": 6.73, "maxrss_mib_p95": 7.16, "parent_maxrss_mib_p50": null, "wall_ms_p50": 12, "wall_ms_p95": 22, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.73 / 6.67 / 6.73 MiB); p95 is RES-28's line at 314 KB (7.16 MiB), which the archive applies from P-10 in place of 8 / 10 MiB; 60 runs gave p95 6.92, max 6.95"}, + {"id": "recap.stop", "events": ["Stop", "StopFailure"], "command": ["recap"], "payload_class": "stop_100", "payload_bytes": 2048, "benchmark": "BenchmarkRecap/100", "alloc_bytes_max": 1074929, "maxrss_mib_p50": 7.06, "maxrss_mib_p95": 16, "parent_maxrss_mib_p50": null, "wall_ms_p50": 20, "wall_ms_p95": 60, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (7.06 / 7.03 / 7.06 MiB); on the 100-call session BenchmarkRecap/100 reads; expected per archive 85.4.1: 12 / 16 MiB, flat in session size"}, + {"id": "recap.pending", "events": ["UserPromptSubmit"], "command": ["recap", "pending"], "payload_class": "prompt", "payload_bytes": 256, "benchmark": "BenchmarkRecapPending/10", "alloc_bytes_max": 261660, "maxrss_mib_p50": 6.06, "maxrss_mib_p95": 7.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 8, "wall_ms_p95": 20, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.09 / 6.06 / 5.98 MiB); on the 10-call session BenchmarkRecapPending/10 reads (the earlier 5.2 measured the no-store early return); expected per archive 85.4.1: 6.5 / 7.5 MiB"}, {"id": "probe.start", "events": ["SessionStart"], "command": ["probe", "start"], "payload_class": "session_start", "payload_bytes": 256, "benchmark": "", "alloc_bytes_max": 0, "maxrss_mib_p50": 5.97, "maxrss_mib_p95": 24, "parent_maxrss_mib_p50": null, "wall_ms_p50": 15, "wall_ms_p95": 400, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.02 / 5.97 / 5.91 MiB); nothing queued; expected per archive 85.4.1: 8 MiB with nothing queued at p50, mining and briefing at p95"}, {"id": "probe.end", "events": ["SessionEnd"], "command": ["probe", "end"], "payload_class": "session_end", "payload_bytes": 256, "benchmark": "", "alloc_bytes_max": 0, "maxrss_mib_p50": 5.98, "maxrss_mib_p95": 6.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 6, "wall_ms_p95": 12, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.95 / 6.05 / 5.98 MiB); closing a started session; expected per archive 85.4.1: one record, 6 / 6.5 MiB"} ], @@ -32,28 +32,28 @@ {"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 2656, "measured_allocs_per_op": 33, "bytes_per_op_max": 2656, "allocs_per_op_max": 33}, {"name": "BenchmarkDerive/bash_long_4k", "measured_bytes_per_op": 268944, "measured_allocs_per_op": 229, "bytes_per_op_max": 268944, "allocs_per_op_max": 229}, {"name": "BenchmarkDerive/edit_50k", "measured_bytes_per_op": 63464, "measured_allocs_per_op": 28, "bytes_per_op_max": 63464, "allocs_per_op_max": 28}, - {"name": "BenchmarkLexTokens", "measured_bytes_per_op": 128544, "measured_allocs_per_op": 750, "bytes_per_op_max": 128544, "allocs_per_op_max": 750}, + {"name": "BenchmarkLexTokens", "measured_bytes_per_op": 99184, "measured_allocs_per_op": 90, "bytes_per_op_max": 99184, "allocs_per_op_max": 90}, {"name": "BenchmarkRecordBytesPerCall", "measured_bytes_per_op": 17838, "measured_allocs_per_op": 95, "bytes_per_op_max": 17838, "allocs_per_op_max": 95}, - {"name": "BenchmarkBuild/188", "measured_bytes_per_op": 1886832, "measured_allocs_per_op": 6828, "bytes_per_op_max": 1886832, "allocs_per_op_max": 6828}, - {"name": "BenchmarkBuild/899", "measured_bytes_per_op": 8422345, "measured_allocs_per_op": 31708, "bytes_per_op_max": 8422345, "allocs_per_op_max": 31708}, - {"name": "BenchmarkBuild/3488", "measured_bytes_per_op": 36024352, "measured_allocs_per_op": 121991, "bytes_per_op_max": 36024352, "allocs_per_op_max": 121991}, - {"name": "BenchmarkBuild/20000", "measured_bytes_per_op": 235467679, "measured_allocs_per_op": 696819, "bytes_per_op_max": 235467679, "allocs_per_op_max": 696819}, - {"name": "BenchmarkRunHook/bash_short", "measured_bytes_per_op": 54058, "measured_allocs_per_op": 362, "bytes_per_op_max": 54058, "allocs_per_op_max": 362}, - {"name": "BenchmarkRunHook/edit_50k", "measured_bytes_per_op": 218378, "measured_allocs_per_op": 310, "bytes_per_op_max": 218378, "allocs_per_op_max": 310}, - {"name": "BenchmarkRunPost/bash_short", "measured_bytes_per_op": 46893, "measured_allocs_per_op": 290, "bytes_per_op_max": 46893, "allocs_per_op_max": 290}, - {"name": "BenchmarkRunPost/edit_314k", "measured_bytes_per_op": 892802, "measured_allocs_per_op": 295, "bytes_per_op_max": 892802, "allocs_per_op_max": 295}, - {"name": "BenchmarkRecap/10", "measured_bytes_per_op": 263952, "measured_allocs_per_op": 558, "bytes_per_op_max": 263952, "allocs_per_op_max": 558}, - {"name": "BenchmarkRecap/100", "measured_bytes_per_op": 1075559, "measured_allocs_per_op": 3849, "bytes_per_op_max": 1075559, "allocs_per_op_max": 3849}, - {"name": "BenchmarkRecap/1000", "measured_bytes_per_op": 9513616, "measured_allocs_per_op": 35359, "bytes_per_op_max": 9513616, "allocs_per_op_max": 35359}, - {"name": "BenchmarkRecapPending/10", "measured_bytes_per_op": 261190, "measured_allocs_per_op": 555, "bytes_per_op_max": 261190, "allocs_per_op_max": 555}, - {"name": "BenchmarkRecapPending/1000", "measured_bytes_per_op": 9511308, "measured_allocs_per_op": 35356, "bytes_per_op_max": 9511308, "allocs_per_op_max": 35356}, - {"name": "BenchmarkHookStoreSequence/full", "measured_bytes_per_op": 15778, "measured_allocs_per_op": 79, "bytes_per_op_max": 15778, "allocs_per_op_max": 79}, - {"name": "BenchmarkHookStoreSequence/deferred", "measured_bytes_per_op": 16426, "measured_allocs_per_op": 83, "bytes_per_op_max": 16426, "allocs_per_op_max": 83}, - {"name": "BenchmarkAppendDeclarationParallel", "measured_bytes_per_op": 483310, "measured_allocs_per_op": 3142, "bytes_per_op_max": 483310, "allocs_per_op_max": 3142}, - {"name": "BenchmarkOrderedAppendHold/serial_full", "measured_bytes_per_op": 10646, "measured_allocs_per_op": 47, "bytes_per_op_max": 10646, "allocs_per_op_max": 47}, - {"name": "BenchmarkOrderedAppendHold/serial_deferred", "measured_bytes_per_op": 11351, "measured_allocs_per_op": 51, "bytes_per_op_max": 11351, "allocs_per_op_max": 51}, - {"name": "BenchmarkOrderedAppendHold/parallel_full", "measured_bytes_per_op": 478744, "measured_allocs_per_op": 2949, "bytes_per_op_max": 478744, "allocs_per_op_max": 2949}, - {"name": "BenchmarkOrderedAppendHold/parallel_deferred", "measured_bytes_per_op": 499297, "measured_allocs_per_op": 3138, "bytes_per_op_max": 499297, "allocs_per_op_max": 3138}, + {"name": "BenchmarkBuild/188", "measured_bytes_per_op": 1887148, "measured_allocs_per_op": 6829, "bytes_per_op_max": 1887148, "allocs_per_op_max": 6829}, + {"name": "BenchmarkBuild/899", "measured_bytes_per_op": 8423210, "measured_allocs_per_op": 31711, "bytes_per_op_max": 8423210, "allocs_per_op_max": 31711}, + {"name": "BenchmarkBuild/3488", "measured_bytes_per_op": 36007381, "measured_allocs_per_op": 121982, "bytes_per_op_max": 36007381, "allocs_per_op_max": 121982}, + {"name": "BenchmarkBuild/20000", "measured_bytes_per_op": 235465389, "measured_allocs_per_op": 696796, "bytes_per_op_max": 235465389, "allocs_per_op_max": 696796}, + {"name": "BenchmarkRunHook/bash_short", "measured_bytes_per_op": 50534, "measured_allocs_per_op": 301, "bytes_per_op_max": 50534, "allocs_per_op_max": 301}, + {"name": "BenchmarkRunHook/edit_50k", "measured_bytes_per_op": 218256, "measured_allocs_per_op": 310, "bytes_per_op_max": 218256, "allocs_per_op_max": 310}, + {"name": "BenchmarkRunPost/bash_short", "measured_bytes_per_op": 45146, "measured_allocs_per_op": 252, "bytes_per_op_max": 45146, "allocs_per_op_max": 252}, + {"name": "BenchmarkRunPost/edit_314k", "measured_bytes_per_op": 891098, "measured_allocs_per_op": 293, "bytes_per_op_max": 891098, "allocs_per_op_max": 293}, + {"name": "BenchmarkRecap/10", "measured_bytes_per_op": 263735, "measured_allocs_per_op": 559, "bytes_per_op_max": 263735, "allocs_per_op_max": 559}, + {"name": "BenchmarkRecap/100", "measured_bytes_per_op": 1074929, "measured_allocs_per_op": 3844, "bytes_per_op_max": 1074929, "allocs_per_op_max": 3844}, + {"name": "BenchmarkRecap/1000", "measured_bytes_per_op": 9513196, "measured_allocs_per_op": 35351, "bytes_per_op_max": 9513196, "allocs_per_op_max": 35351}, + {"name": "BenchmarkRecapPending/10", "measured_bytes_per_op": 261660, "measured_allocs_per_op": 558, "bytes_per_op_max": 261660, "allocs_per_op_max": 558}, + {"name": "BenchmarkRecapPending/1000", "measured_bytes_per_op": 9511994, "measured_allocs_per_op": 35359, "bytes_per_op_max": 9511994, "allocs_per_op_max": 35359}, + {"name": "BenchmarkHookStoreSequence/full", "measured_bytes_per_op": 15434, "measured_allocs_per_op": 79, "bytes_per_op_max": 15434, "allocs_per_op_max": 79}, + {"name": "BenchmarkHookStoreSequence/deferred", "measured_bytes_per_op": 16114, "measured_allocs_per_op": 83, "bytes_per_op_max": 16114, "allocs_per_op_max": 83}, + {"name": "BenchmarkAppendDeclarationParallel", "measured_bytes_per_op": 483825, "measured_allocs_per_op": 3144, "bytes_per_op_max": 483825, "allocs_per_op_max": 3144}, + {"name": "BenchmarkOrderedAppendHold/serial_full", "measured_bytes_per_op": 10582, "measured_allocs_per_op": 47, "bytes_per_op_max": 10582, "allocs_per_op_max": 47}, + {"name": "BenchmarkOrderedAppendHold/serial_deferred", "measured_bytes_per_op": 11006, "measured_allocs_per_op": 50, "bytes_per_op_max": 11006, "allocs_per_op_max": 50}, + {"name": "BenchmarkOrderedAppendHold/parallel_full", "measured_bytes_per_op": 479550, "measured_allocs_per_op": 2953, "bytes_per_op_max": 479550, "allocs_per_op_max": 2953}, + {"name": "BenchmarkOrderedAppendHold/parallel_deferred", "measured_bytes_per_op": 499403, "measured_allocs_per_op": 3140, "bytes_per_op_max": 499403, "allocs_per_op_max": 3140}, {"name": "BenchmarkTop", "measured_bytes_per_op": 616, "measured_allocs_per_op": 6, "bytes_per_op_max": 616, "allocs_per_op_max": 6}, {"name": "BenchmarkLines/4MiB", "measured_bytes_per_op": 24, "measured_allocs_per_op": 2, "bytes_per_op_max": 24, "allocs_per_op_max": 2}, {"name": "BenchmarkStream/64MiB_batch", "measured_bytes_per_op": 158321, "measured_allocs_per_op": 17, "bytes_per_op_max": 158321, "allocs_per_op_max": 17}, @@ -61,7 +61,7 @@ {"name": "BenchmarkRead/400B", "measured_bytes_per_op": 4584, "measured_allocs_per_op": 4, "bytes_per_op_max": 4584, "allocs_per_op_max": 4}, {"name": "BenchmarkRead/40KB", "measured_bytes_per_op": 83456, "measured_allocs_per_op": 16, "bytes_per_op_max": 83456, "allocs_per_op_max": 16}, {"name": "BenchmarkRead/1MiB", "measured_bytes_per_op": 2163589, "measured_allocs_per_op": 48, "bytes_per_op_max": 2163589, "allocs_per_op_max": 48}, - {"name": "BenchmarkRead/4MiB", "measured_bytes_per_op": 8459141, "measured_allocs_per_op": 97, "bytes_per_op_max": 8459141, "allocs_per_op_max": 97}, + {"name": "BenchmarkRead/4MiB", "measured_bytes_per_op": 8459136, "measured_allocs_per_op": 97, "bytes_per_op_max": 8459136, "allocs_per_op_max": 97}, {"name": "BenchmarkRead/8MiB", "measured_bytes_per_op": 16823168, "measured_allocs_per_op": 161, "bytes_per_op_max": 16823168, "allocs_per_op_max": 161} ], "growth": [], From a3bdbdf4542501a9f6469e8d9ba3f60aca76dc27 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Thu, 22 Oct 2026 03:50:22 -0700 Subject: [PATCH 48/50] perf: measure shape-bounds' linux/amd64 benchmark rows on this tree Measured by the measure-linux-benchmarks job on 47b2217, run 39765459422 (best of three, -benchtime 20x, go1.27.3). Twenty-five rows move, most of them down with the shape work: BenchmarkDerive/bash_long_4k 430,689 -> 268,944 B and 2,333 -> 229 allocs, BenchmarkLexTokens 128,544 -> 99,184 B, BenchmarkRunHook/bash_short 336 -> 274 allocs. set_from names the run, and internal/budget/budgets_gen.go is regenerated from the file. --- internal/budget/budgets_gen.go | 2 +- test/perf/budgets.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/budget/budgets_gen.go b/internal/budget/budgets_gen.go index 61168f05..abae7cdf 100644 --- a/internal/budget/budgets_gen.go +++ b/internal/budget/budgets_gen.go @@ -3,4 +3,4 @@ package budget // tableJSON is test/perf/budgets.json, compacted; Table decodes it on first use. -const tableJSON = "{\"budgets_version\":1,\"go_version\":\"go1.27.3\",\"benchmark_rows\":\"the measured best of three on the reference machine (darwin/arm64, go1.27.3, -benchtime 20x, 2026-10-22); a row's max equals its measurement, and the gate's ten percent (scripts/benchgate) is the only margin\",\"benchmark_platform\":\"darwin/arm64\",\"platforms\":{\"darwin/arm64\":{\"reference\":\"Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \\\"-s -w\\\")\",\"set_from\":\"measured 2026-10-13 on the P-06 tree (archive 85.1.2)\",\"tolerance\":0.15,\"tolerance_until\":\"2026-11-06\",\"tolerance_after\":0.1,\"entries\":{},\"case_budgets\":{}},\"linux/amd64\":{\"reference\":\"ubuntu-latest\",\"set_from\":\"measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on d2071b6 by the measure-linux-benchmarks job, run 39762603829 (best of three, -benchtime 20x)\",\"tolerance\":0.1,\"tolerance_until\":\"\",\"tolerance_after\":0.1,\"entries\":{\"hook.bash_short\":{\"p50\":7.71,\"p95\":7.71},\"post.bash_short\":{\"p50\":7.65,\"p95\":7.65},\"hook.edit_50k\":{\"p50\":9.67,\"p95\":9.67},\"post.edit_314k\":{\"p50\":7.83,\"p95\":7.83},\"recap.stop\":{\"p50\":8.07,\"p95\":8.07},\"recap.pending\":{\"p50\":7.93,\"p95\":7.93},\"probe.start\":{\"p50\":7.64,\"p95\":7.64},\"probe.end\":{\"p50\":7.66,\"p95\":7.66}},\"case_budgets\":{\"hook.400B\":7.64,\"hook.40KB\":7.7,\"post.540B\":7.65,\"post.40KB\":7.65},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":4624,\"measured_allocs_per_op\":71,\"bytes_per_op_max\":4624,\"allocs_per_op_max\":71},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":430689,\"measured_allocs_per_op\":2333,\"bytes_per_op_max\":430689,\"allocs_per_op_max\":2333},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":128544,\"measured_allocs_per_op\":750,\"bytes_per_op_max\":128544,\"allocs_per_op_max\":750},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":16550,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":16550,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1885613,\"measured_allocs_per_op\":6829,\"bytes_per_op_max\":1885613,\"allocs_per_op_max\":6829},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8417557,\"measured_allocs_per_op\":31710,\"bytes_per_op_max\":8417557,\"allocs_per_op_max\":31710},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36038866,\"measured_allocs_per_op\":122027,\"bytes_per_op_max\":36038866,\"allocs_per_op_max\":122027},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235463295,\"measured_allocs_per_op\":696851,\"bytes_per_op_max\":235463295,\"allocs_per_op_max\":696851},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":50194,\"measured_allocs_per_op\":336,\"bytes_per_op_max\":50194,\"allocs_per_op_max\":336},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":213832,\"measured_allocs_per_op\":282,\"bytes_per_op_max\":213832,\"allocs_per_op_max\":282},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":43419,\"measured_allocs_per_op\":263,\"bytes_per_op_max\":43419,\"allocs_per_op_max\":263},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":885698,\"measured_allocs_per_op\":262,\"bytes_per_op_max\":885698,\"allocs_per_op_max\":262},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":261837,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":261837,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1072366,\"measured_allocs_per_op\":3840,\"bytes_per_op_max\":1072366,\"allocs_per_op_max\":3840},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9506120,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9506120,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":260018,\"measured_allocs_per_op\":556,\"bytes_per_op_max\":260018,\"allocs_per_op_max\":556},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9503936,\"measured_allocs_per_op\":35345,\"bytes_per_op_max\":9503936,\"allocs_per_op_max\":35345},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":14273,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14273,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":14778,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14778,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":432603,\"measured_allocs_per_op\":2948,\"bytes_per_op_max\":432603,\"allocs_per_op_max\":2948},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10061,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10061,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":10407,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":10407,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":440815,\"measured_allocs_per_op\":2946,\"bytes_per_op_max\":440815,\"allocs_per_op_max\":2946},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":440657,\"measured_allocs_per_op\":2945,\"bytes_per_op_max\":440657,\"allocs_per_op_max\":2945},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163584,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163584,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}]}},\"entries\":[{\"id\":\"hook.bash_short\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"bash_short\",\"payload_bytes\":400,\"benchmark\":\"BenchmarkRunHook/bash_short\",\"alloc_bytes_max\":50534,\"maxrss_mib_p50\":5.95,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":10,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths\"},{\"id\":\"hook.edit_50k\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"edit_50k\",\"payload_bytes\":51200,\"benchmark\":\"BenchmarkRunHook/edit_50k\",\"alloc_bytes_max\":218256,\"maxrss_mib_p50\":6.20,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":25,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB\"},{\"id\":\"post.bash_short\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"bash_short\",\"payload_bytes\":540,\"benchmark\":\"BenchmarkRunPost/bash_short\",\"alloc_bytes_max\":45146,\"maxrss_mib_p50\":5.94,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":11,\"wall_ms_p95\":18,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.94 / 5.89 / 6.00 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"post.edit_314k\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"edit_314k\",\"payload_bytes\":314000,\"benchmark\":\"BenchmarkRunPost/edit_314k\",\"alloc_bytes_max\":891098,\"maxrss_mib_p50\":6.73,\"maxrss_mib_p95\":7.16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":12,\"wall_ms_p95\":22,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.73 / 6.67 / 6.73 MiB); p95 is RES-28's line at 314 KB (7.16 MiB), which the archive applies from P-10 in place of 8 / 10 MiB; 60 runs gave p95 6.92, max 6.95\"},{\"id\":\"recap.stop\",\"events\":[\"Stop\",\"StopFailure\"],\"command\":[\"recap\"],\"payload_class\":\"stop_100\",\"payload_bytes\":2048,\"benchmark\":\"BenchmarkRecap/100\",\"alloc_bytes_max\":1074929,\"maxrss_mib_p50\":7.06,\"maxrss_mib_p95\":16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":20,\"wall_ms_p95\":60,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (7.06 / 7.03 / 7.06 MiB); on the 100-call session BenchmarkRecap/100 reads; expected per archive 85.4.1: 12 / 16 MiB, flat in session size\"},{\"id\":\"recap.pending\",\"events\":[\"UserPromptSubmit\"],\"command\":[\"recap\",\"pending\"],\"payload_class\":\"prompt\",\"payload_bytes\":256,\"benchmark\":\"BenchmarkRecapPending/10\",\"alloc_bytes_max\":261660,\"maxrss_mib_p50\":6.06,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":8,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.09 / 6.06 / 5.98 MiB); on the 10-call session BenchmarkRecapPending/10 reads (the earlier 5.2 measured the no-store early return); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"probe.start\",\"events\":[\"SessionStart\"],\"command\":[\"probe\",\"start\"],\"payload_class\":\"session_start\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.97,\"maxrss_mib_p95\":24,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":400,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.02 / 5.97 / 5.91 MiB); nothing queued; expected per archive 85.4.1: 8 MiB with nothing queued at p50, mining and briefing at p95\"},{\"id\":\"probe.end\",\"events\":[\"SessionEnd\"],\"command\":[\"probe\",\"end\"],\"payload_class\":\"session_end\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.98,\"maxrss_mib_p95\":6.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":6,\"wall_ms_p95\":12,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.95 / 6.05 / 5.98 MiB); closing a started session; expected per archive 85.4.1: one record, 6 / 6.5 MiB\"}],\"rss_cases\":[{\"id\":\"hook.400B\",\"entry\":\"hook\",\"payload_bytes\":400,\"budget_mib\":6.5008,\"measured_mib\":5.97,\"gates_from\":\"P-08\"},{\"id\":\"hook.40KB\",\"entry\":\"hook\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.12,\"gates_from\":\"P-08\"},{\"id\":\"hook.1MiB\",\"entry\":\"hook\",\"payload_bytes\":1048576,\"budget_mib\":9.9,\"measured_mib\":9.48,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.4MiB\",\"entry\":\"hook\",\"payload_bytes\":4194304,\"budget_mib\":20.1,\"measured_mib\":19.72,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.8MiB\",\"entry\":\"hook\",\"payload_bytes\":8388608,\"budget_mib\":33.7,\"measured_mib\":31.88,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.540B\",\"entry\":\"post\",\"payload_bytes\":540,\"budget_mib\":6.5011,\"measured_mib\":6.06,\"gates_from\":\"P-08\"},{\"id\":\"post.40KB\",\"entry\":\"post\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.05,\"gates_from\":\"P-08\"},{\"id\":\"post.1MiB\",\"entry\":\"post\",\"payload_bytes\":1048576,\"budget_mib\":8.95,\"measured_mib\":8.86,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.4MiB\",\"entry\":\"post\",\"payload_bytes\":4194304,\"budget_mib\":16.3,\"measured_mib\":15.42,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.8MiB\",\"entry\":\"post\",\"payload_bytes\":8388608,\"budget_mib\":26.1,\"measured_mib\":23.66,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"}],\"per_tool_call\":{\"memory_time_mib_ms_max\":379,\"summed_peak_mib_max\":32,\"process_creations_max\":{\"settings\":6,\"plugin\":6}},\"parallel\":{\"ways\":64,\"summed_peak_mib_max\":704,\"basis\":\"per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured\"},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":2656,\"measured_allocs_per_op\":33,\"bytes_per_op_max\":2656,\"allocs_per_op_max\":33},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":268944,\"measured_allocs_per_op\":229,\"bytes_per_op_max\":268944,\"allocs_per_op_max\":229},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":99184,\"measured_allocs_per_op\":90,\"bytes_per_op_max\":99184,\"allocs_per_op_max\":90},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":17838,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":17838,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1887148,\"measured_allocs_per_op\":6829,\"bytes_per_op_max\":1887148,\"allocs_per_op_max\":6829},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8423210,\"measured_allocs_per_op\":31711,\"bytes_per_op_max\":8423210,\"allocs_per_op_max\":31711},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36007381,\"measured_allocs_per_op\":121982,\"bytes_per_op_max\":36007381,\"allocs_per_op_max\":121982},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235465389,\"measured_allocs_per_op\":696796,\"bytes_per_op_max\":235465389,\"allocs_per_op_max\":696796},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":50534,\"measured_allocs_per_op\":301,\"bytes_per_op_max\":50534,\"allocs_per_op_max\":301},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":218256,\"measured_allocs_per_op\":310,\"bytes_per_op_max\":218256,\"allocs_per_op_max\":310},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":45146,\"measured_allocs_per_op\":252,\"bytes_per_op_max\":45146,\"allocs_per_op_max\":252},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":891098,\"measured_allocs_per_op\":293,\"bytes_per_op_max\":891098,\"allocs_per_op_max\":293},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":263735,\"measured_allocs_per_op\":559,\"bytes_per_op_max\":263735,\"allocs_per_op_max\":559},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1074929,\"measured_allocs_per_op\":3844,\"bytes_per_op_max\":1074929,\"allocs_per_op_max\":3844},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9513196,\"measured_allocs_per_op\":35351,\"bytes_per_op_max\":9513196,\"allocs_per_op_max\":35351},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":261660,\"measured_allocs_per_op\":558,\"bytes_per_op_max\":261660,\"allocs_per_op_max\":558},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9511994,\"measured_allocs_per_op\":35359,\"bytes_per_op_max\":9511994,\"allocs_per_op_max\":35359},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":15434,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":15434,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":16114,\"measured_allocs_per_op\":83,\"bytes_per_op_max\":16114,\"allocs_per_op_max\":83},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":483825,\"measured_allocs_per_op\":3144,\"bytes_per_op_max\":483825,\"allocs_per_op_max\":3144},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10582,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10582,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":11006,\"measured_allocs_per_op\":50,\"bytes_per_op_max\":11006,\"allocs_per_op_max\":50},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":479550,\"measured_allocs_per_op\":2953,\"bytes_per_op_max\":479550,\"allocs_per_op_max\":2953},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":499403,\"measured_allocs_per_op\":3140,\"bytes_per_op_max\":499403,\"allocs_per_op_max\":3140},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163589,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163589,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}],\"growth\":[],\"disk\":{\"bytes_per_call_max\":6000}}" +const tableJSON = "{\"budgets_version\":1,\"go_version\":\"go1.27.3\",\"benchmark_rows\":\"the measured best of three on the reference machine (darwin/arm64, go1.27.3, -benchtime 20x, 2026-10-22); a row's max equals its measurement, and the gate's ten percent (scripts/benchgate) is the only margin\",\"benchmark_platform\":\"darwin/arm64\",\"platforms\":{\"darwin/arm64\":{\"reference\":\"Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \\\"-s -w\\\")\",\"set_from\":\"measured 2026-10-13 on the P-06 tree (archive 85.1.2)\",\"tolerance\":0.15,\"tolerance_until\":\"2026-11-06\",\"tolerance_after\":0.1,\"entries\":{},\"case_budgets\":{}},\"linux/amd64\":{\"reference\":\"ubuntu-latest\",\"set_from\":\"measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on 47b2217 by the measure-linux-benchmarks job, run 39765459422 (best of three, -benchtime 20x)\",\"tolerance\":0.1,\"tolerance_until\":\"\",\"tolerance_after\":0.1,\"entries\":{\"hook.bash_short\":{\"p50\":7.71,\"p95\":7.71},\"post.bash_short\":{\"p50\":7.65,\"p95\":7.65},\"hook.edit_50k\":{\"p50\":9.67,\"p95\":9.67},\"post.edit_314k\":{\"p50\":7.83,\"p95\":7.83},\"recap.stop\":{\"p50\":8.07,\"p95\":8.07},\"recap.pending\":{\"p50\":7.93,\"p95\":7.93},\"probe.start\":{\"p50\":7.64,\"p95\":7.64},\"probe.end\":{\"p50\":7.66,\"p95\":7.66}},\"case_budgets\":{\"hook.400B\":7.64,\"hook.40KB\":7.7,\"post.540B\":7.65,\"post.40KB\":7.65},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":2656,\"measured_allocs_per_op\":33,\"bytes_per_op_max\":2656,\"allocs_per_op_max\":33},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":268944,\"measured_allocs_per_op\":229,\"bytes_per_op_max\":268944,\"allocs_per_op_max\":229},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":99184,\"measured_allocs_per_op\":90,\"bytes_per_op_max\":99184,\"allocs_per_op_max\":90},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":16465,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":16465,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1885361,\"measured_allocs_per_op\":6830,\"bytes_per_op_max\":1885361,\"allocs_per_op_max\":6830},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8418931,\"measured_allocs_per_op\":31710,\"bytes_per_op_max\":8418931,\"allocs_per_op_max\":31710},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36023019,\"measured_allocs_per_op\":122013,\"bytes_per_op_max\":36023019,\"allocs_per_op_max\":122013},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235458568,\"measured_allocs_per_op\":696825,\"bytes_per_op_max\":235458568,\"allocs_per_op_max\":696825},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":46742,\"measured_allocs_per_op\":274,\"bytes_per_op_max\":46742,\"allocs_per_op_max\":274},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":214284,\"measured_allocs_per_op\":283,\"bytes_per_op_max\":214284,\"allocs_per_op_max\":283},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":41447,\"measured_allocs_per_op\":225,\"bytes_per_op_max\":41447,\"allocs_per_op_max\":225},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":886581,\"measured_allocs_per_op\":264,\"bytes_per_op_max\":886581,\"allocs_per_op_max\":264},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":261542,\"measured_allocs_per_op\":554,\"bytes_per_op_max\":261542,\"allocs_per_op_max\":554},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1072023,\"measured_allocs_per_op\":3840,\"bytes_per_op_max\":1072023,\"allocs_per_op_max\":3840},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9507176,\"measured_allocs_per_op\":35349,\"bytes_per_op_max\":9507176,\"allocs_per_op_max\":35349},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":259734,\"measured_allocs_per_op\":555,\"bytes_per_op_max\":259734,\"allocs_per_op_max\":555},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9505414,\"measured_allocs_per_op\":35351,\"bytes_per_op_max\":9505414,\"allocs_per_op_max\":35351},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":14657,\"measured_allocs_per_op\":80,\"bytes_per_op_max\":14657,\"allocs_per_op_max\":80},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":14433,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":14433,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":432372,\"measured_allocs_per_op\":2946,\"bytes_per_op_max\":432372,\"allocs_per_op_max\":2946},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10045,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10045,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":10407,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":10407,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":440672,\"measured_allocs_per_op\":2946,\"bytes_per_op_max\":440672,\"allocs_per_op_max\":2946},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":440645,\"measured_allocs_per_op\":2946,\"bytes_per_op_max\":440645,\"allocs_per_op_max\":2946},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158322,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158322,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163584,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163584,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823173,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823173,\"allocs_per_op_max\":161}]}},\"entries\":[{\"id\":\"hook.bash_short\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"bash_short\",\"payload_bytes\":400,\"benchmark\":\"BenchmarkRunHook/bash_short\",\"alloc_bytes_max\":50534,\"maxrss_mib_p50\":5.95,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":10,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths\"},{\"id\":\"hook.edit_50k\",\"events\":[\"PreToolUse\"],\"command\":[\"hook\"],\"payload_class\":\"edit_50k\",\"payload_bytes\":51200,\"benchmark\":\"BenchmarkRunHook/edit_50k\",\"alloc_bytes_max\":218256,\"maxrss_mib_p50\":6.20,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":25,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB\"},{\"id\":\"post.bash_short\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"bash_short\",\"payload_bytes\":540,\"benchmark\":\"BenchmarkRunPost/bash_short\",\"alloc_bytes_max\":45146,\"maxrss_mib_p50\":5.94,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":11,\"wall_ms_p95\":18,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.94 / 5.89 / 6.00 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"post.edit_314k\",\"events\":[\"PostToolUse\",\"PostToolUseFailure\"],\"command\":[\"post\"],\"payload_class\":\"edit_314k\",\"payload_bytes\":314000,\"benchmark\":\"BenchmarkRunPost/edit_314k\",\"alloc_bytes_max\":891098,\"maxrss_mib_p50\":6.73,\"maxrss_mib_p95\":7.16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":12,\"wall_ms_p95\":22,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.73 / 6.67 / 6.73 MiB); p95 is RES-28's line at 314 KB (7.16 MiB), which the archive applies from P-10 in place of 8 / 10 MiB; 60 runs gave p95 6.92, max 6.95\"},{\"id\":\"recap.stop\",\"events\":[\"Stop\",\"StopFailure\"],\"command\":[\"recap\"],\"payload_class\":\"stop_100\",\"payload_bytes\":2048,\"benchmark\":\"BenchmarkRecap/100\",\"alloc_bytes_max\":1074929,\"maxrss_mib_p50\":7.06,\"maxrss_mib_p95\":16,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":20,\"wall_ms_p95\":60,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (7.06 / 7.03 / 7.06 MiB); on the 100-call session BenchmarkRecap/100 reads; expected per archive 85.4.1: 12 / 16 MiB, flat in session size\"},{\"id\":\"recap.pending\",\"events\":[\"UserPromptSubmit\"],\"command\":[\"recap\",\"pending\"],\"payload_class\":\"prompt\",\"payload_bytes\":256,\"benchmark\":\"BenchmarkRecapPending/10\",\"alloc_bytes_max\":261660,\"maxrss_mib_p50\":6.06,\"maxrss_mib_p95\":7.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":8,\"wall_ms_p95\":20,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.09 / 6.06 / 5.98 MiB); on the 10-call session BenchmarkRecapPending/10 reads (the earlier 5.2 measured the no-store early return); expected per archive 85.4.1: 6.5 / 7.5 MiB\"},{\"id\":\"probe.start\",\"events\":[\"SessionStart\"],\"command\":[\"probe\",\"start\"],\"payload_class\":\"session_start\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.97,\"maxrss_mib_p95\":24,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":15,\"wall_ms_p95\":400,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.02 / 5.97 / 5.91 MiB); nothing queued; expected per archive 85.4.1: 8 MiB with nothing queued at p50, mining and briefing at p95\"},{\"id\":\"probe.end\",\"events\":[\"SessionEnd\"],\"command\":[\"probe\",\"end\"],\"payload_class\":\"session_end\",\"payload_bytes\":256,\"benchmark\":\"\",\"alloc_bytes_max\":0,\"maxrss_mib_p50\":5.98,\"maxrss_mib_p95\":6.5,\"parent_maxrss_mib_p50\":null,\"wall_ms_p50\":6,\"wall_ms_p95\":12,\"basis\":\"measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.95 / 6.05 / 5.98 MiB); closing a started session; expected per archive 85.4.1: one record, 6 / 6.5 MiB\"}],\"rss_cases\":[{\"id\":\"hook.400B\",\"entry\":\"hook\",\"payload_bytes\":400,\"budget_mib\":6.5008,\"measured_mib\":5.97,\"gates_from\":\"P-08\"},{\"id\":\"hook.40KB\",\"entry\":\"hook\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.12,\"gates_from\":\"P-08\"},{\"id\":\"hook.1MiB\",\"entry\":\"hook\",\"payload_bytes\":1048576,\"budget_mib\":9.9,\"measured_mib\":9.48,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.4MiB\",\"entry\":\"hook\",\"payload_bytes\":4194304,\"budget_mib\":20.1,\"measured_mib\":19.72,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"hook.8MiB\",\"entry\":\"hook\",\"payload_bytes\":8388608,\"budget_mib\":33.7,\"measured_mib\":31.88,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":3.4,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input, whose constant overhead shows most at 1 MiB, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.540B\",\"entry\":\"post\",\"payload_bytes\":540,\"budget_mib\":6.5011,\"measured_mib\":6.06,\"gates_from\":\"P-08\"},{\"id\":\"post.40KB\",\"entry\":\"post\",\"payload_bytes\":40000,\"budget_mib\":6.5839,\"measured_mib\":6.05,\"gates_from\":\"P-08\"},{\"id\":\"post.1MiB\",\"entry\":\"post\",\"payload_bytes\":1048576,\"budget_mib\":8.95,\"measured_mib\":8.86,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.4MiB\",\"entry\":\"post\",\"payload_bytes\":4194304,\"budget_mib\":16.3,\"measured_mib\":15.42,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"},{\"id\":\"post.8MiB\",\"entry\":\"post\",\"payload_bytes\":8388608,\"budget_mib\":26.1,\"measured_mib\":23.66,\"gates_from\":\"P-10\",\"slope_mib_per_mib\":2.45,\"basis\":\"RES-4's 2.0x read plus canonical's decoded copy of tool_input for the executed digest, until RES-48 (archive 85.3.3)\"}],\"per_tool_call\":{\"memory_time_mib_ms_max\":379,\"summed_peak_mib_max\":32,\"process_creations_max\":{\"settings\":6,\"plugin\":6}},\"parallel\":{\"ways\":64,\"summed_peak_mib_max\":704,\"basis\":\"per-process rule (RES-30, ruling 2026-10-13): largest pair peak at most 1.25x the median of 25 solo pairs, both measured in the same run. Restated from measurement on the reference machine with GOMAXPROCS unchanged: largest 1.08x to 1.21x over six runs with nine solo pairs, 1.10x to 1.19x over eleven runs with 25 (4.02 to 4.31 MiB against solo medians of 3.61 to 3.69 MiB), burst median 1.02x to 1.05x; GOMAXPROCS(1) in hook and post measured 1.03x to 1.09x over twenty runs and was rejected. No tolerance on the reference machine; macos-latest unmeasured, so a CI runner gets the platform tolerance on the ratio until it is measured\"},\"benchmarks\":[{\"name\":\"BenchmarkDerive/bash_short\",\"measured_bytes_per_op\":2656,\"measured_allocs_per_op\":33,\"bytes_per_op_max\":2656,\"allocs_per_op_max\":33},{\"name\":\"BenchmarkDerive/bash_long_4k\",\"measured_bytes_per_op\":268944,\"measured_allocs_per_op\":229,\"bytes_per_op_max\":268944,\"allocs_per_op_max\":229},{\"name\":\"BenchmarkDerive/edit_50k\",\"measured_bytes_per_op\":63464,\"measured_allocs_per_op\":28,\"bytes_per_op_max\":63464,\"allocs_per_op_max\":28},{\"name\":\"BenchmarkLexTokens\",\"measured_bytes_per_op\":99184,\"measured_allocs_per_op\":90,\"bytes_per_op_max\":99184,\"allocs_per_op_max\":90},{\"name\":\"BenchmarkRecordBytesPerCall\",\"measured_bytes_per_op\":17838,\"measured_allocs_per_op\":95,\"bytes_per_op_max\":17838,\"allocs_per_op_max\":95},{\"name\":\"BenchmarkBuild/188\",\"measured_bytes_per_op\":1887148,\"measured_allocs_per_op\":6829,\"bytes_per_op_max\":1887148,\"allocs_per_op_max\":6829},{\"name\":\"BenchmarkBuild/899\",\"measured_bytes_per_op\":8423210,\"measured_allocs_per_op\":31711,\"bytes_per_op_max\":8423210,\"allocs_per_op_max\":31711},{\"name\":\"BenchmarkBuild/3488\",\"measured_bytes_per_op\":36007381,\"measured_allocs_per_op\":121982,\"bytes_per_op_max\":36007381,\"allocs_per_op_max\":121982},{\"name\":\"BenchmarkBuild/20000\",\"measured_bytes_per_op\":235465389,\"measured_allocs_per_op\":696796,\"bytes_per_op_max\":235465389,\"allocs_per_op_max\":696796},{\"name\":\"BenchmarkRunHook/bash_short\",\"measured_bytes_per_op\":50534,\"measured_allocs_per_op\":301,\"bytes_per_op_max\":50534,\"allocs_per_op_max\":301},{\"name\":\"BenchmarkRunHook/edit_50k\",\"measured_bytes_per_op\":218256,\"measured_allocs_per_op\":310,\"bytes_per_op_max\":218256,\"allocs_per_op_max\":310},{\"name\":\"BenchmarkRunPost/bash_short\",\"measured_bytes_per_op\":45146,\"measured_allocs_per_op\":252,\"bytes_per_op_max\":45146,\"allocs_per_op_max\":252},{\"name\":\"BenchmarkRunPost/edit_314k\",\"measured_bytes_per_op\":891098,\"measured_allocs_per_op\":293,\"bytes_per_op_max\":891098,\"allocs_per_op_max\":293},{\"name\":\"BenchmarkRecap/10\",\"measured_bytes_per_op\":263735,\"measured_allocs_per_op\":559,\"bytes_per_op_max\":263735,\"allocs_per_op_max\":559},{\"name\":\"BenchmarkRecap/100\",\"measured_bytes_per_op\":1074929,\"measured_allocs_per_op\":3844,\"bytes_per_op_max\":1074929,\"allocs_per_op_max\":3844},{\"name\":\"BenchmarkRecap/1000\",\"measured_bytes_per_op\":9513196,\"measured_allocs_per_op\":35351,\"bytes_per_op_max\":9513196,\"allocs_per_op_max\":35351},{\"name\":\"BenchmarkRecapPending/10\",\"measured_bytes_per_op\":261660,\"measured_allocs_per_op\":558,\"bytes_per_op_max\":261660,\"allocs_per_op_max\":558},{\"name\":\"BenchmarkRecapPending/1000\",\"measured_bytes_per_op\":9511994,\"measured_allocs_per_op\":35359,\"bytes_per_op_max\":9511994,\"allocs_per_op_max\":35359},{\"name\":\"BenchmarkHookStoreSequence/full\",\"measured_bytes_per_op\":15434,\"measured_allocs_per_op\":79,\"bytes_per_op_max\":15434,\"allocs_per_op_max\":79},{\"name\":\"BenchmarkHookStoreSequence/deferred\",\"measured_bytes_per_op\":16114,\"measured_allocs_per_op\":83,\"bytes_per_op_max\":16114,\"allocs_per_op_max\":83},{\"name\":\"BenchmarkAppendDeclarationParallel\",\"measured_bytes_per_op\":483825,\"measured_allocs_per_op\":3144,\"bytes_per_op_max\":483825,\"allocs_per_op_max\":3144},{\"name\":\"BenchmarkOrderedAppendHold/serial_full\",\"measured_bytes_per_op\":10582,\"measured_allocs_per_op\":47,\"bytes_per_op_max\":10582,\"allocs_per_op_max\":47},{\"name\":\"BenchmarkOrderedAppendHold/serial_deferred\",\"measured_bytes_per_op\":11006,\"measured_allocs_per_op\":50,\"bytes_per_op_max\":11006,\"allocs_per_op_max\":50},{\"name\":\"BenchmarkOrderedAppendHold/parallel_full\",\"measured_bytes_per_op\":479550,\"measured_allocs_per_op\":2953,\"bytes_per_op_max\":479550,\"allocs_per_op_max\":2953},{\"name\":\"BenchmarkOrderedAppendHold/parallel_deferred\",\"measured_bytes_per_op\":499403,\"measured_allocs_per_op\":3140,\"bytes_per_op_max\":499403,\"allocs_per_op_max\":3140},{\"name\":\"BenchmarkTop\",\"measured_bytes_per_op\":616,\"measured_allocs_per_op\":6,\"bytes_per_op_max\":616,\"allocs_per_op_max\":6},{\"name\":\"BenchmarkLines/4MiB\",\"measured_bytes_per_op\":24,\"measured_allocs_per_op\":2,\"bytes_per_op_max\":24,\"allocs_per_op_max\":2},{\"name\":\"BenchmarkStream/64MiB_batch\",\"measured_bytes_per_op\":158321,\"measured_allocs_per_op\":17,\"bytes_per_op_max\":158321,\"allocs_per_op_max\":17},{\"name\":\"BenchmarkStream/187MB_line\",\"measured_bytes_per_op\":349379,\"measured_allocs_per_op\":30,\"bytes_per_op_max\":349379,\"allocs_per_op_max\":30},{\"name\":\"BenchmarkRead/400B\",\"measured_bytes_per_op\":4584,\"measured_allocs_per_op\":4,\"bytes_per_op_max\":4584,\"allocs_per_op_max\":4},{\"name\":\"BenchmarkRead/40KB\",\"measured_bytes_per_op\":83456,\"measured_allocs_per_op\":16,\"bytes_per_op_max\":83456,\"allocs_per_op_max\":16},{\"name\":\"BenchmarkRead/1MiB\",\"measured_bytes_per_op\":2163589,\"measured_allocs_per_op\":48,\"bytes_per_op_max\":2163589,\"allocs_per_op_max\":48},{\"name\":\"BenchmarkRead/4MiB\",\"measured_bytes_per_op\":8459136,\"measured_allocs_per_op\":97,\"bytes_per_op_max\":8459136,\"allocs_per_op_max\":97},{\"name\":\"BenchmarkRead/8MiB\",\"measured_bytes_per_op\":16823168,\"measured_allocs_per_op\":161,\"bytes_per_op_max\":16823168,\"allocs_per_op_max\":161}],\"growth\":[],\"disk\":{\"bytes_per_call_max\":6000}}" diff --git a/test/perf/budgets.json b/test/perf/budgets.json index fca8e735..2a35fbd5 100644 --- a/test/perf/budgets.json +++ b/test/perf/budgets.json @@ -3,7 +3,7 @@ "go_version": "go1.27.3", "benchmark_rows": "the measured best of three on the reference machine (darwin/arm64, go1.27.3, -benchtime 20x, 2026-10-22); a row's max equals its measurement, and the gate's ten percent (scripts/benchgate) is the only margin", "benchmark_platform": "darwin/arm64", - "platforms": {"darwin/arm64": {"reference": "Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \"-s -w\")", "set_from": "measured 2026-10-13 on the P-06 tree (archive 85.1.2)", "tolerance": 0.15, "tolerance_until": "2026-11-06", "tolerance_after": 0.1, "entries": {}, "case_budgets": {}}, "linux/amd64": {"reference": "ubuntu-latest", "set_from": "measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on d2071b6 by the measure-linux-benchmarks job, run 39762603829 (best of three, -benchtime 20x)", "tolerance": 0.1, "tolerance_until": "", "tolerance_after": 0.1, "entries": {"hook.bash_short": {"p50": 7.71, "p95": 7.71}, "post.bash_short": {"p50": 7.65, "p95": 7.65}, "hook.edit_50k": {"p50": 9.67, "p95": 9.67}, "post.edit_314k": {"p50": 7.83, "p95": 7.83}, "recap.stop": {"p50": 8.07, "p95": 8.07}, "recap.pending": {"p50": 7.93, "p95": 7.93}, "probe.start": {"p50": 7.64, "p95": 7.64}, "probe.end": {"p50": 7.66, "p95": 7.66}}, "case_budgets": {"hook.400B": 7.64, "hook.40KB": 7.7, "post.540B": 7.65, "post.40KB": 7.65}, "benchmarks": [{"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 4624, "measured_allocs_per_op": 71, "bytes_per_op_max": 4624, "allocs_per_op_max": 71}, {"name": "BenchmarkDerive/bash_long_4k", "measured_bytes_per_op": 430689, "measured_allocs_per_op": 2333, "bytes_per_op_max": 430689, "allocs_per_op_max": 2333}, {"name": "BenchmarkDerive/edit_50k", "measured_bytes_per_op": 63464, "measured_allocs_per_op": 28, "bytes_per_op_max": 63464, "allocs_per_op_max": 28}, {"name": "BenchmarkLexTokens", "measured_bytes_per_op": 128544, "measured_allocs_per_op": 750, "bytes_per_op_max": 128544, "allocs_per_op_max": 750}, {"name": "BenchmarkRecordBytesPerCall", "measured_bytes_per_op": 16550, "measured_allocs_per_op": 95, "bytes_per_op_max": 16550, "allocs_per_op_max": 95}, {"name": "BenchmarkBuild/188", "measured_bytes_per_op": 1885613, "measured_allocs_per_op": 6829, "bytes_per_op_max": 1885613, "allocs_per_op_max": 6829}, {"name": "BenchmarkBuild/899", "measured_bytes_per_op": 8417557, "measured_allocs_per_op": 31710, "bytes_per_op_max": 8417557, "allocs_per_op_max": 31710}, {"name": "BenchmarkBuild/3488", "measured_bytes_per_op": 36038866, "measured_allocs_per_op": 122027, "bytes_per_op_max": 36038866, "allocs_per_op_max": 122027}, {"name": "BenchmarkBuild/20000", "measured_bytes_per_op": 235463295, "measured_allocs_per_op": 696851, "bytes_per_op_max": 235463295, "allocs_per_op_max": 696851}, {"name": "BenchmarkRunHook/bash_short", "measured_bytes_per_op": 50194, "measured_allocs_per_op": 336, "bytes_per_op_max": 50194, "allocs_per_op_max": 336}, {"name": "BenchmarkRunHook/edit_50k", "measured_bytes_per_op": 213832, "measured_allocs_per_op": 282, "bytes_per_op_max": 213832, "allocs_per_op_max": 282}, {"name": "BenchmarkRunPost/bash_short", "measured_bytes_per_op": 43419, "measured_allocs_per_op": 263, "bytes_per_op_max": 43419, "allocs_per_op_max": 263}, {"name": "BenchmarkRunPost/edit_314k", "measured_bytes_per_op": 885698, "measured_allocs_per_op": 262, "bytes_per_op_max": 885698, "allocs_per_op_max": 262}, {"name": "BenchmarkRecap/10", "measured_bytes_per_op": 261837, "measured_allocs_per_op": 555, "bytes_per_op_max": 261837, "allocs_per_op_max": 555}, {"name": "BenchmarkRecap/100", "measured_bytes_per_op": 1072366, "measured_allocs_per_op": 3840, "bytes_per_op_max": 1072366, "allocs_per_op_max": 3840}, {"name": "BenchmarkRecap/1000", "measured_bytes_per_op": 9506120, "measured_allocs_per_op": 35345, "bytes_per_op_max": 9506120, "allocs_per_op_max": 35345}, {"name": "BenchmarkRecapPending/10", "measured_bytes_per_op": 260018, "measured_allocs_per_op": 556, "bytes_per_op_max": 260018, "allocs_per_op_max": 556}, {"name": "BenchmarkRecapPending/1000", "measured_bytes_per_op": 9503936, "measured_allocs_per_op": 35345, "bytes_per_op_max": 9503936, "allocs_per_op_max": 35345}, {"name": "BenchmarkHookStoreSequence/full", "measured_bytes_per_op": 14273, "measured_allocs_per_op": 79, "bytes_per_op_max": 14273, "allocs_per_op_max": 79}, {"name": "BenchmarkHookStoreSequence/deferred", "measured_bytes_per_op": 14778, "measured_allocs_per_op": 79, "bytes_per_op_max": 14778, "allocs_per_op_max": 79}, {"name": "BenchmarkAppendDeclarationParallel", "measured_bytes_per_op": 432603, "measured_allocs_per_op": 2948, "bytes_per_op_max": 432603, "allocs_per_op_max": 2948}, {"name": "BenchmarkOrderedAppendHold/serial_full", "measured_bytes_per_op": 10061, "measured_allocs_per_op": 47, "bytes_per_op_max": 10061, "allocs_per_op_max": 47}, {"name": "BenchmarkOrderedAppendHold/serial_deferred", "measured_bytes_per_op": 10407, "measured_allocs_per_op": 48, "bytes_per_op_max": 10407, "allocs_per_op_max": 48}, {"name": "BenchmarkOrderedAppendHold/parallel_full", "measured_bytes_per_op": 440815, "measured_allocs_per_op": 2946, "bytes_per_op_max": 440815, "allocs_per_op_max": 2946}, {"name": "BenchmarkOrderedAppendHold/parallel_deferred", "measured_bytes_per_op": 440657, "measured_allocs_per_op": 2945, "bytes_per_op_max": 440657, "allocs_per_op_max": 2945}, {"name": "BenchmarkTop", "measured_bytes_per_op": 616, "measured_allocs_per_op": 6, "bytes_per_op_max": 616, "allocs_per_op_max": 6}, {"name": "BenchmarkLines/4MiB", "measured_bytes_per_op": 24, "measured_allocs_per_op": 2, "bytes_per_op_max": 24, "allocs_per_op_max": 2}, {"name": "BenchmarkStream/64MiB_batch", "measured_bytes_per_op": 158321, "measured_allocs_per_op": 17, "bytes_per_op_max": 158321, "allocs_per_op_max": 17}, {"name": "BenchmarkStream/187MB_line", "measured_bytes_per_op": 349379, "measured_allocs_per_op": 30, "bytes_per_op_max": 349379, "allocs_per_op_max": 30}, {"name": "BenchmarkRead/400B", "measured_bytes_per_op": 4584, "measured_allocs_per_op": 4, "bytes_per_op_max": 4584, "allocs_per_op_max": 4}, {"name": "BenchmarkRead/40KB", "measured_bytes_per_op": 83456, "measured_allocs_per_op": 16, "bytes_per_op_max": 83456, "allocs_per_op_max": 16}, {"name": "BenchmarkRead/1MiB", "measured_bytes_per_op": 2163584, "measured_allocs_per_op": 48, "bytes_per_op_max": 2163584, "allocs_per_op_max": 48}, {"name": "BenchmarkRead/4MiB", "measured_bytes_per_op": 8459136, "measured_allocs_per_op": 97, "bytes_per_op_max": 8459136, "allocs_per_op_max": 97}, {"name": "BenchmarkRead/8MiB", "measured_bytes_per_op": 16823168, "measured_allocs_per_op": 161, "bytes_per_op_max": 16823168, "allocs_per_op_max": 161}]}}, + "platforms": {"darwin/arm64": {"reference": "Apple M3, 8 GB, go1.27.2 (every RSS and init gate re-run under go1.27.3 on 2026-10-22 and within its row), release flags (CGO_ENABLED=0 -trimpath -ldflags \"-s -w\")", "set_from": "measured 2026-10-13 on the P-06 tree (archive 85.1.2)", "tolerance": 0.15, "tolerance_until": "2026-11-06", "tolerance_after": 0.1, "entries": {}, "case_budgets": {}}, "linux/amd64": {"reference": "ubuntu-latest", "set_from": "measured 2026-10-17 on the reviewed head (739e94c) through the reaper, job 117482527831; hook.bash_short and post.bash_short are run 39750645601's medians (7.71 / 7.65 MiB) on 6baab66, on a runner whose reaper floor was that job's 5.98 MiB: that job read them under the floor, so not attributably, and its 5.88 / 5.90 MiB were of older code, not a ceiling for this one; benchmarks measured 2026-10-22 under go1.27.3 on 47b2217 by the measure-linux-benchmarks job, run 39765459422 (best of three, -benchtime 20x)", "tolerance": 0.1, "tolerance_until": "", "tolerance_after": 0.1, "entries": {"hook.bash_short": {"p50": 7.71, "p95": 7.71}, "post.bash_short": {"p50": 7.65, "p95": 7.65}, "hook.edit_50k": {"p50": 9.67, "p95": 9.67}, "post.edit_314k": {"p50": 7.83, "p95": 7.83}, "recap.stop": {"p50": 8.07, "p95": 8.07}, "recap.pending": {"p50": 7.93, "p95": 7.93}, "probe.start": {"p50": 7.64, "p95": 7.64}, "probe.end": {"p50": 7.66, "p95": 7.66}}, "case_budgets": {"hook.400B": 7.64, "hook.40KB": 7.7, "post.540B": 7.65, "post.40KB": 7.65}, "benchmarks": [{"name": "BenchmarkDerive/bash_short", "measured_bytes_per_op": 2656, "measured_allocs_per_op": 33, "bytes_per_op_max": 2656, "allocs_per_op_max": 33}, {"name": "BenchmarkDerive/bash_long_4k", "measured_bytes_per_op": 268944, "measured_allocs_per_op": 229, "bytes_per_op_max": 268944, "allocs_per_op_max": 229}, {"name": "BenchmarkDerive/edit_50k", "measured_bytes_per_op": 63464, "measured_allocs_per_op": 28, "bytes_per_op_max": 63464, "allocs_per_op_max": 28}, {"name": "BenchmarkLexTokens", "measured_bytes_per_op": 99184, "measured_allocs_per_op": 90, "bytes_per_op_max": 99184, "allocs_per_op_max": 90}, {"name": "BenchmarkRecordBytesPerCall", "measured_bytes_per_op": 16465, "measured_allocs_per_op": 95, "bytes_per_op_max": 16465, "allocs_per_op_max": 95}, {"name": "BenchmarkBuild/188", "measured_bytes_per_op": 1885361, "measured_allocs_per_op": 6830, "bytes_per_op_max": 1885361, "allocs_per_op_max": 6830}, {"name": "BenchmarkBuild/899", "measured_bytes_per_op": 8418931, "measured_allocs_per_op": 31710, "bytes_per_op_max": 8418931, "allocs_per_op_max": 31710}, {"name": "BenchmarkBuild/3488", "measured_bytes_per_op": 36023019, "measured_allocs_per_op": 122013, "bytes_per_op_max": 36023019, "allocs_per_op_max": 122013}, {"name": "BenchmarkBuild/20000", "measured_bytes_per_op": 235458568, "measured_allocs_per_op": 696825, "bytes_per_op_max": 235458568, "allocs_per_op_max": 696825}, {"name": "BenchmarkRunHook/bash_short", "measured_bytes_per_op": 46742, "measured_allocs_per_op": 274, "bytes_per_op_max": 46742, "allocs_per_op_max": 274}, {"name": "BenchmarkRunHook/edit_50k", "measured_bytes_per_op": 214284, "measured_allocs_per_op": 283, "bytes_per_op_max": 214284, "allocs_per_op_max": 283}, {"name": "BenchmarkRunPost/bash_short", "measured_bytes_per_op": 41447, "measured_allocs_per_op": 225, "bytes_per_op_max": 41447, "allocs_per_op_max": 225}, {"name": "BenchmarkRunPost/edit_314k", "measured_bytes_per_op": 886581, "measured_allocs_per_op": 264, "bytes_per_op_max": 886581, "allocs_per_op_max": 264}, {"name": "BenchmarkRecap/10", "measured_bytes_per_op": 261542, "measured_allocs_per_op": 554, "bytes_per_op_max": 261542, "allocs_per_op_max": 554}, {"name": "BenchmarkRecap/100", "measured_bytes_per_op": 1072023, "measured_allocs_per_op": 3840, "bytes_per_op_max": 1072023, "allocs_per_op_max": 3840}, {"name": "BenchmarkRecap/1000", "measured_bytes_per_op": 9507176, "measured_allocs_per_op": 35349, "bytes_per_op_max": 9507176, "allocs_per_op_max": 35349}, {"name": "BenchmarkRecapPending/10", "measured_bytes_per_op": 259734, "measured_allocs_per_op": 555, "bytes_per_op_max": 259734, "allocs_per_op_max": 555}, {"name": "BenchmarkRecapPending/1000", "measured_bytes_per_op": 9505414, "measured_allocs_per_op": 35351, "bytes_per_op_max": 9505414, "allocs_per_op_max": 35351}, {"name": "BenchmarkHookStoreSequence/full", "measured_bytes_per_op": 14657, "measured_allocs_per_op": 80, "bytes_per_op_max": 14657, "allocs_per_op_max": 80}, {"name": "BenchmarkHookStoreSequence/deferred", "measured_bytes_per_op": 14433, "measured_allocs_per_op": 79, "bytes_per_op_max": 14433, "allocs_per_op_max": 79}, {"name": "BenchmarkAppendDeclarationParallel", "measured_bytes_per_op": 432372, "measured_allocs_per_op": 2946, "bytes_per_op_max": 432372, "allocs_per_op_max": 2946}, {"name": "BenchmarkOrderedAppendHold/serial_full", "measured_bytes_per_op": 10045, "measured_allocs_per_op": 47, "bytes_per_op_max": 10045, "allocs_per_op_max": 47}, {"name": "BenchmarkOrderedAppendHold/serial_deferred", "measured_bytes_per_op": 10407, "measured_allocs_per_op": 48, "bytes_per_op_max": 10407, "allocs_per_op_max": 48}, {"name": "BenchmarkOrderedAppendHold/parallel_full", "measured_bytes_per_op": 440672, "measured_allocs_per_op": 2946, "bytes_per_op_max": 440672, "allocs_per_op_max": 2946}, {"name": "BenchmarkOrderedAppendHold/parallel_deferred", "measured_bytes_per_op": 440645, "measured_allocs_per_op": 2946, "bytes_per_op_max": 440645, "allocs_per_op_max": 2946}, {"name": "BenchmarkTop", "measured_bytes_per_op": 616, "measured_allocs_per_op": 6, "bytes_per_op_max": 616, "allocs_per_op_max": 6}, {"name": "BenchmarkLines/4MiB", "measured_bytes_per_op": 24, "measured_allocs_per_op": 2, "bytes_per_op_max": 24, "allocs_per_op_max": 2}, {"name": "BenchmarkStream/64MiB_batch", "measured_bytes_per_op": 158322, "measured_allocs_per_op": 17, "bytes_per_op_max": 158322, "allocs_per_op_max": 17}, {"name": "BenchmarkStream/187MB_line", "measured_bytes_per_op": 349379, "measured_allocs_per_op": 30, "bytes_per_op_max": 349379, "allocs_per_op_max": 30}, {"name": "BenchmarkRead/400B", "measured_bytes_per_op": 4584, "measured_allocs_per_op": 4, "bytes_per_op_max": 4584, "allocs_per_op_max": 4}, {"name": "BenchmarkRead/40KB", "measured_bytes_per_op": 83456, "measured_allocs_per_op": 16, "bytes_per_op_max": 83456, "allocs_per_op_max": 16}, {"name": "BenchmarkRead/1MiB", "measured_bytes_per_op": 2163584, "measured_allocs_per_op": 48, "bytes_per_op_max": 2163584, "allocs_per_op_max": 48}, {"name": "BenchmarkRead/4MiB", "measured_bytes_per_op": 8459136, "measured_allocs_per_op": 97, "bytes_per_op_max": 8459136, "allocs_per_op_max": 97}, {"name": "BenchmarkRead/8MiB", "measured_bytes_per_op": 16823173, "measured_allocs_per_op": 161, "bytes_per_op_max": 16823173, "allocs_per_op_max": 161}]}}, "entries": [ {"id": "hook.bash_short", "events": ["PreToolUse"], "command": ["hook"], "payload_class": "bash_short", "payload_bytes": 400, "benchmark": "BenchmarkRunHook/bash_short", "alloc_bytes_max": 50534, "maxrss_mib_p50": 5.95, "maxrss_mib_p95": 7.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 10, "wall_ms_p95": 20, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (5.89 / 5.97 / 5.95 MiB); expected per archive 85.4.1: 6.5 / 7.5 MiB, the floor of about 5.9 under the pin plus the masker, targets and written paths"}, {"id": "hook.edit_50k", "events": ["PreToolUse"], "command": ["hook"], "payload_class": "edit_50k", "payload_bytes": 51200, "benchmark": "BenchmarkRunHook/edit_50k", "alloc_bytes_max": 218256, "maxrss_mib_p50": 6.20, "maxrss_mib_p95": 7.5, "parent_maxrss_mib_p50": null, "wall_ms_p50": 15, "wall_ms_p95": 25, "basis": "measured 2026-10-13 on the reference machine (go1.27.2, release flags; TestRES28b_ on a pinned, installed store; remeasured on the P-10 tree, bounded intake over group commit): median of nine, the middle of three runs (6.12 / 6.23 / 6.20 MiB); expected per RES-28: 6.61 MiB at 50 KB"}, From c2801d1449e9e0fc45e88917bab743afe8c38bda Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Thu, 22 Oct 2026 04:18:55 -0700 Subject: [PATCH 49/50] shape: a cut authority is refused, not recorded by its username The RES-29 mutant that returns the window's prefix instead of refusing it did not compile, so the sweep counted it undetected without ever running it. Made to compile, it survived: TestAuthorityWindowBoundsTheScan gates wall time, and the truncated scan is as fast as the refused one. TestAuthorityWindowRefusesACutAuthority gives it a clone URL whose token runs past the window. Truncated, the prefix keeps one colon and no '@', host.Canonical's port strip cuts at the colon, and x-access-token is recorded as the host the call named. --- internal/shape/hosts_authority_window_test.go | 17 +++++++++++++++++ .../mutation/mutants/RES-29_authority-window.py | 12 +++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/internal/shape/hosts_authority_window_test.go b/internal/shape/hosts_authority_window_test.go index 9645392a..814a9fea 100644 --- a/internal/shape/hosts_authority_window_test.go +++ b/internal/shape/hosts_authority_window_test.go @@ -37,3 +37,20 @@ func TestAuthorityWindowBoundsTheScan(t *testing.T) { }) } } + +// TestAuthorityWindowRefusesACutAuthority: past the window, authority refuses +// the candidate rather than handing host.Canonical the window's prefix. A +// credential longer than the window -- a CI token or a JWT in a clone URL's +// userinfo runs to hundreds of bytes -- puts the '@' past it, so the prefix +// is "user:": no '@' left for Canonical to refuse, +// one colon for its port strip to cut at, and the username recorded as the +// host the call named, the CWE-522 outcome authority's doc rules out. +func TestAuthorityWindowRefusesACutAuthority(t *testing.T) { + line := "git clone https://x-access-token:" + strings.Repeat("t", 300) + "@github.com/org/repo" + wire, ssh := Hosts("Bash", bashInput(t, line)) + for _, h := range append(wire, ssh...) { + if h == "x-access-token" { + t.Errorf("Hosts(%q...) = %q, %q: the userinfo's username recorded as a host", line[:40], wire, ssh) + } + } +} diff --git a/test/mutation/mutants/RES-29_authority-window.py b/test/mutation/mutants/RES-29_authority-window.py index aece4923..90017368 100644 --- a/test/mutation/mutants/RES-29_authority-window.py +++ b/test/mutation/mutants/RES-29_authority-window.py @@ -1,9 +1,11 @@ # RES-29: authority's scan for its terminator is bounded to authorityWindow -# bytes, past host.maxName plus room for a port. Lifting the window, or -# returning a truncated candidate instead of refusing one past it, each put -# the unbounded scan -- to the end of a command line at every scheme match -- -# back. +# bytes, past host.maxName plus room for a port. Lifting the window puts the +# unbounded scan -- to the end of a command line at every scheme match -- +# back. Returning the window's prefix instead of refusing a candidate cut +# there keeps the scan bounded but hands host.Canonical a prefix that has +# lost the '@' of a credential longer than the window, so the username is +# recorded as a host. m("RES-29 the authority scan window is lifted", "internal/shape/hosts.go", "const authorityWindow = 253 + 8\n", "const authorityWindow = 1 << 30\n", "TestAuthorityWindowBoundsTheScan") m("RES-29 a cut authority is truncated instead of refused", "internal/shape/hosts.go", - "\tif cut {\n\t\treturn \"\"\n\t}\n\treturn limit\n", "\treturn limit\n", "TestAuthorityWindowBoundsTheScan") + "\tif cut {\n\t\treturn \"\"\n\t}\n\treturn limit\n", "\t_ = cut\n\treturn limit\n", "TestAuthorityWindowRefusesACutAuthority") From 15ad66b6b082238f12d21847f5746b2e14d5a6f5 Mon Sep 17 00:00:00 2001 From: Karthik Nerella Date: Thu, 22 Oct 2026 05:08:07 -0700 Subject: [PATCH 50/50] mutation: TRU-58's clears-nothing mutant follows forget --host into ForgetAll forget --host has walked the baseline directory in ForgetAll, under one lock, since it stopped calling ForgetFile per file. The mutant that makes forget clear nothing still emptied ForgetFile, which the CLI no longer reaches, so TRU-58b's end-to-end tests could not see it. It now skips every file in ForgetAll's walk; ForgetFile's guard keeps a mutant of its own, judged by the tests that call it. --- test/mutation/mutants/TRU-58_baseline-basis.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/mutation/mutants/TRU-58_baseline-basis.py b/test/mutation/mutants/TRU-58_baseline-basis.py index 43fd993a..7d8c6e7a 100644 --- a/test/mutation/mutants/TRU-58_baseline-basis.py +++ b/test/mutation/mutants/TRU-58_baseline-basis.py @@ -16,12 +16,20 @@ # The baseline half of forget --host, end to end. A forget that kept the host # in a current baseline would leave it "seen before" there, so a later session # naming it again would never be told it is new. Judged by TRU-58b alone: its -# third session names the host after the forget. +# third session names the host after the forget. forget --host walks the +# baseline directory in ForgetAll, under one lock, so a walk that visits no +# file is where it clears nothing. m("TRU-58 forget keeps the host in a current baseline", "internal/baseline/baseline.go", "\t\t\tdelete(f.Hosts, h)\n", "", "TestTRU58b_") m("TRU-58 forget clears no baseline at all", "internal/baseline/baseline.go", - "\tif path == \"\" {\n\t\treturn 0, nil\n\t}\n", "\tif true {\n\t\treturn 0, nil\n\t}\n", + "\t\tif e.IsDir() || !strings.HasSuffix(e.Name(), \".json\") {\n\t\t\tcontinue\n\t\t}\n\t\tn, err := forgetLocked(", + "\t\tif true {\n\t\t\tcontinue\n\t\t}\n\t\tn, err := forgetLocked(", "TestTRU58b_") +# ForgetFile is the one-file form, which forget --host no longer calls; its own +# tests judge it. +m("TRU-58 ForgetFile clears no baseline at all", "internal/baseline/baseline.go", + "\tif path == \"\" {\n\t\treturn 0, nil\n\t}\n", "\tif true {\n\t\treturn 0, nil\n\t}\n", + "TestTRU58_") # TRU-58c: a baseline this build cannot parse, or of a version it does not # know, cannot be rewritten without the host. Holding the host, it is deleted # and counted; not holding it, it is left as it was.