diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d657e..69b14a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,3 +79,10 @@ there is no earlier bundle to compare it against. writing about itself is [decisions/0014-what-the-design-system-page-renders.md](decisions/0014-what-the-design-system-page-renders.md), and the page prints the counts of both so the split can be read off the page. +- The gate refuses a limit a native client is held to, written into anything the + build reads to render a page. The five numbers are the pinned token copy's, + and the row is handed them rather than carrying them, so it follows the copy + the day one moves. It reads both spellings the same value arrives in, the + words the page states it in and the number with its unit alone, and a limit + written in any other unit walks through, which is the same bound the row about + a typed colour declares for itself. diff --git a/internal/gate/gate.go b/internal/gate/gate.go index 6db1266..771b4ff 100644 --- a/internal/gate/gate.go +++ b/internal/gate/gate.go @@ -241,7 +241,9 @@ func invariantsLeg(root string) (string, error) { lines := strings.Split(strings.TrimRight(log.String(), "\n"), "\n") return "", fmt.Errorf("%v:\n%s", err, indent(lines)) } - rules, owed := len(invariant.Rules()), len(invariant.Owing()) + // The count of rows rather than their decisions, which the run above has + // already taken, so the table is handed nothing. + rules, owed := len(invariant.Rules(nil)), len(invariant.Owing()) return fmt.Sprintf("ok, %d rule(s) decided, %d owed and not decided", rules, owed), nil } diff --git a/internal/invariant/invariant.go b/internal/invariant/invariant.go index ac96895..6ac2c87 100644 --- a/internal/invariant/invariant.go +++ b/internal/invariant/invariant.go @@ -257,7 +257,18 @@ const affiliationNotice = "Flowfin is not affiliated with the Jellyfin project." const contentElement = "main" // Rules is the table. The order is the order a run reports them in. -func Rules() []Rule { +// +// It is handed the numbers a client is held to, because one row compares against +// values rather than against a shape, and there is no other way for a value to +// reach a row: a row is given the bytes of one file and nothing else. Carrying +// the numbers in this file instead would make it the second declaration of the +// file the row exists to keep as the only one, which is the failure with the +// name of the rule on it. +// +// What the table holds does not depend on what it is handed. Every row is here +// on every call, and only what one of them compares against moves, so a caller +// that wants the count rather than the decisions may hand it nothing. +func Rules(numbers []tokens.Number) []Rule { return []Rule{ { ID: "page-declares-its-language", @@ -469,6 +480,13 @@ func Rules() []Rule { Refuses: "a colour, a length, a font family or a font weight written into what the build reads, outside a fragment reference", decide: decideTypedTokenValue, }, + { + ID: "client-budget-numbers-live-in-exactly-one-file", + Subject: BuildInputs, + Reason: "a number a client is held to is the same class of fact as a spacing step, and it is the harder one to see when it goes stale: a wrong colour looks wrong on the page and a wrong millisecond looks like every other millisecond, so a second copy of one is a conformance target somebody meets while the published one says something else", + Refuses: "a limit a client is held to, written into what the build reads, in either the words the page states it in or the number and its unit alone", + decide: decideTypedBudgetNumber(numbers), + }, { ID: "version-lives-in-exactly-one-file", Subject: TrackedTextOutsideTheVersionRegister, @@ -860,6 +878,49 @@ func decideTypedTokenValue(body []byte) []string { return details } +// decideTypedBudgetNumber refuses one of the numbers a client is held to, +// written into something the build reads. +// +// It is the same rule as the one above with a different unit, and the unit is +// what makes it a separate row rather than a fifth shape in that one. A colour, +// a length, a font stack and a weight are recognisable from their own spelling, +// so that row can be told what to look for once and never revisited. A latency +// ceiling is digits and a word, indistinguishable from a transition duration or +// from a figure in a sentence, so the only thing that separates a copy of the +// budget from an unrelated number is the budget itself. That is why this one is +// handed the values and the others are not. +// +// Two spellings per number, and the longer one first so that a line stating the +// limit in the words the page uses is reported as that rather than as the bare +// number inside it. What it compares is exact: a limit written in another unit +// walks through, which is the same bound the row above declares for a colour +// spelled in a form CSS does not read. The file stays the authority for the set, +// and this row is what stops a second copy of one member of it. +// +// A run handed no numbers refuses nothing, which is why Run reads the copy +// before it builds the table and refuses a tree whose copy carries none. A green +// mark from this row over an empty set would say the tree holds no second copy +// of a number, when what it means is that nobody told it what the numbers are. +func decideTypedBudgetNumber(numbers []tokens.Number) func([]byte) []string { + return func(body []byte) []string { + var details []string + for i, line := range strings.Split(string(body), "\n") { + for _, n := range numbers { + for _, spelling := range []string{n.Stated(), n.Bare()} { + if !strings.Contains(line, spelling) { + continue + } + details = append(details, fmt.Sprintf( + "line %d writes %q, which is what %s says %s is, and %s is the one file it is read from", + i+1, spelling, tokens.File, n.Name, tokens.File)) + break + } + } + } + return details + } +} + // decideImports refuses an import that is neither standard library nor inside // this module. A standard library path has no dot in its first element, which is // the rule the toolchain itself uses to tell the two apart. @@ -893,12 +954,42 @@ func Owing() []Owed { } } +// budgetNumbers reads the numbers a client is held to out of the pinned copy, so +// that the row about a second copy of one has something to compare against. +// +// It fails closed in both directions the row cannot survive, and each is refused +// in its own words rather than collapsed into one. A copy that could not be read +// is a tree the row was never decided against. A copy that carries no such +// number is a row that would report ok having compared nothing, which reads as a +// tree holding no second copy and means that nobody said what the numbers are. +// That is the same position gather takes about the copy existing at all, one +// step further in. +func budgetNumbers(root string) ([]tokens.Number, error) { + values, err := tokens.Load(root) + if err != nil { + return nil, fmt.Errorf("the row about where a client budget number is read from cannot be decided: %w", err) + } + numbers, reasons := tokens.Numbers(values) + if len(reasons) > 0 { + return nil, fmt.Errorf("%s, %d reason(s):\n %s", tokens.File, len(reasons), strings.Join(reasons, "\n ")) + } + if len(numbers) == 0 { + return nil, fmt.Errorf("%s carries no client budget number, and the row about where one is read from is a row about there being exactly one file that carries it", tokens.File) + } + return numbers, nil +} + // Run decides every rule against the tree at root and writes what it examined // to log. It builds the site into a directory it throws away, so what the page // rules read is what a build produces rather than whatever is sitting in the // output directory from an earlier one. func Run(root string, log io.Writer) error { - rules := Rules() + numbers, err := budgetNumbers(root) + if err != nil { + return err + } + + rules := Rules(numbers) ids := make([]string, len(rules)) for i, r := range rules { ids[i] = r.ID @@ -2010,8 +2101,12 @@ func decideCitedChecks(body []byte) []string { // decided answers whether a name is a row this gate decides. It reads the table // rather than a list written beside it, so a row added, renamed or removed // changes this answer without anybody remembering to. +// +// The table is asked for its names rather than for its decisions, so it is +// handed nothing. What a row compares against does not change whether it is in +// the table, which is the sentence Rules carries and the suite checks. func decided(name string) bool { - for _, r := range Rules() { + for _, r := range Rules(nil) { if r.ID == name { return true } diff --git a/internal/invariant/invariant_test.go b/internal/invariant/invariant_test.go index 72ee5a1..093acd6 100644 --- a/internal/invariant/invariant_test.go +++ b/internal/invariant/invariant_test.go @@ -14,6 +14,7 @@ package invariant import ( "bytes" "encoding/base64" + "fmt" "io" "os" "os/exec" @@ -40,6 +41,18 @@ func b64(t *testing.T, s string) []byte { return b } +// What the table is handed where a case is about the rows rather than about a +// tree. Two numbers rather than the five the real copy carries, and neither of +// them one of those five: a case asserting against the published limits would +// pass by reading the same file the row reads, which is the one thing this row +// cannot be allowed to prove about itself. One is a ceiling and one is an exact +// value, because the two are spelled differently and a fixture carrying only a +// ceiling would leave the other spelling untried. +var fixtureNumbers = []tokens.Number{ + {Name: "first-frame", Limit: "37", Unit: "ms", Comparison: "below"}, + {Name: "torn-frames", Limit: "4", Unit: "frames", Comparison: "equal"}, +} + // The opening tag of the content, written once because most fixtures below put // their markup next to it and a second spelling of it here would drift against // the page silently: the replacement would find nothing, the fixture would be @@ -237,6 +250,14 @@ func TestEveryRowRefusesItsOwnViolationAndPassesTheNeighbour(t *testing.T) { // published one moves. "design-tokens-live-in-exactly-one-file": []byte(strings.Replace(cleanPage, `
`, ``, 1)), + // The limit quoted in a sentence, which is how a number a client + // is held to gets into prose: somebody explains what the software + // promises and writes the figure down beside it. It is the same + // second definition as the colour above and it looks like an + // ordinary sentence afterwards, which is why nobody finds it. + "client-budget-numbers-live-in-exactly-one-file": []byte(strings.Replace(cleanPage, + `A key press answers `+fixtureNumbers[0].Stated()+`.
`, 1)), // The sentence a document gains the day somebody wants a reader to // know which release they are looking at. It is assembled from the // constant rather than written out, because a test source carrying @@ -261,7 +282,7 @@ func TestEveryRowRefusesItsOwnViolationAndPassesTheNeighbour(t *testing.T) { // wrong reason. cleanTest := b64(t, "cGFja2FnZSBzYW1wbGUKCmltcG9ydCAoCgkib3MiCgkidGVzdGluZyIKKQoKZnVuYyBUZXN0U29tZXRoaW5nKHQgKnRlc3RpbmcuVCkgewoJaWYgb3MuR2V0ZW52KCJIT01FIikgPT0gIiIgewoJCXQuU2tpcCgibm8gaG9tZSIpCgl9Cn0K") - rules := Rules() + rules := Rules(fixtureNumbers) if len(rules) != len(violations) { t.Fatalf("the table holds %d row(s) and this test carries %d violation(s); a row without one proves nothing", len(rules), len(violations)) @@ -566,7 +587,7 @@ func TestTheContentLinkRowLeavesWhatIsOutOfTheOrderAlone(t *testing.T) { // meaning anything. The name is taken out of the table rather than typed here, // so this test cannot go on passing against a row that was renamed. func TestTheCitationRowLeavesARealCheckAlone(t *testing.T) { - real := Rules()[0].ID + real := Rules(fixtureNumbers)[0].ID page := []byte(strings.Replace(cleanPage, contentOpen, contentOpen+`A key press answers "+fixtureNumbers[0].Stated()+".
", 1)) + + var log bytes.Buffer + if err := Run(root, &log); err == nil { + t.Fatalf("Run accepted a build input quoting a client budget limit:\n%s", log.String()) + } + for _, want := range []string{ + "client-budget-numbers-live-in-exactly-one-file: REFUSED, 1 violation(s)", + "templates/page.html.tmpl: line 13", + `"under 37 ms"`, + tokens.File, + } { + if !strings.Contains(log.String(), want) { + t.Errorf("the run does not say %q; it said:\n%s", want, log.String()) + } + } +} + +// The same tree with the sentence taken back out. Without this the case above +// would pass over a run that refuses every tree, which proves the opposite of +// what it is for. +func TestRunAcceptsAFrameThatQuotesNoLimit(t *testing.T) { + var log bytes.Buffer + if err := Run(tree(t, goodTemplate), &log); err != nil { + t.Fatalf("Run refused a tree quoting no limit: %v\n%s", err, log.String()) + } + if !strings.Contains(log.String(), "client-budget-numbers-live-in-exactly-one-file: ok") { + t.Errorf("the run did not report the row as examined; it said:\n%s", log.String()) + } +} + +// A copy carrying no client budget number is refused rather than decided +// against. The row would report ok having compared nothing, which reads as a +// tree holding no second copy of a limit and means that nobody said what the +// limits are. +func TestRunRefusesACopyThatCarriesNoClientBudget(t *testing.T) { + root := tree(t, goodTemplate) + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(tokens.File)), + []byte(`{"surface":{"ground":{"dark":{"srgb":"#121216","alpha":1}}}}`), 0o644); err != nil { + t.Fatalf("rewriting the copy: %v", err) + } + git(t, root, "add", "-A") + + var log bytes.Buffer + err := Run(root, &log) + if err == nil { + t.Fatalf("Run decided the table against a copy carrying no client budget:\n%s", log.String()) + } + if !strings.Contains(err.Error(), "carries no client budget number") { + t.Errorf("the refusal reads %q, which does not say what was missing", err) + } + if strings.Contains(log.String(), "client-budget-numbers-live-in-exactly-one-file: ok") { + t.Errorf("the run reported the row as examined against a copy with nothing in it:\n%s", log.String()) + } +} + +// A number the copy carries with no unit beside it is refused before any row is +// decided, and the refusal names the number rather than the file alone. A limit +// with no unit states nothing, so a row comparing against it would be comparing +// against a bare figure and would refuse every line that happened to carry it. +func TestRunRefusesACopyWhoseLimitCarriesNoUnit(t *testing.T) { + root := tree(t, goodTemplate) + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(tokens.File)), + []byte(`{"budget":{"numbers":{"first-frame":{"limit":37,"comparison":"below"}}}}`), 0o644); err != nil { + t.Fatalf("rewriting the copy: %v", err) + } + git(t, root, "add", "-A") + + var log bytes.Buffer + err := Run(root, &log) + if err == nil { + t.Fatalf("Run decided the table against a limit that states nothing:\n%s", log.String()) + } + if !strings.Contains(err.Error(), "budget.numbers.first-frame") { + t.Errorf("the refusal reads %q, which does not name the number", err) + } +} + // The link to the content is the first thing in the frame, so the one line // deleted takes it off every page at once. The row over a page says that page // is wrong; this says the frame is, and the two are told apart by both produced @@ -1078,7 +1202,7 @@ func TestAnImageWithoutItsDimensionsRedsExactlyOneRow(t *testing.T) { contentOpen+`A key press answers `+fixtureNumbers[0].Stated()+`.
`, 1)) + + var refused []string + for _, r := range Rules(fixtureNumbers) { + if len(r.decide(body)) > 0 { + refused = append(refused, r.ID) + } + } + if len(refused) != 1 || refused[0] != "client-budget-numbers-live-in-exactly-one-file" { + t.Errorf("the typed limit refused %v, want only client-budget-numbers-live-in-exactly-one-file", refused) + } +} + func TestMotionInTheCascadeRedsExactlyOneRow(t *testing.T) { moving := []byte(strings.Replace(cleanPage, ``, " \n ", 1)) var refused []string - for _, r := range Rules() { + for _, r := range Rules(fixtureNumbers) { if len(r.decide(moving)) > 0 { refused = append(refused, r.ID) } diff --git a/internal/site/designsystem.go b/internal/site/designsystem.go index b68a921..521c065 100644 --- a/internal/site/designsystem.go +++ b/internal/site/designsystem.go @@ -491,6 +491,11 @@ func endingIn(values tokens.Values, segment string) []string { // number are prose about the file, which is exactly what a budget row needs // beside a limit, and they are the reason the rule above keeps them rather than // discarding them. +// +// How a limit is spelled is asked of the token package rather than decided here. +// The invariant row that refuses a second copy of one of these numbers has to +// look for the same strings this table prints, and a page and a row each +// carrying their own spelling would part company on the day either moved. func clientBudget(values tokens.Values) (budgetTable, []string) { t := budgetTable{ Whose: "What a native client has to meet", @@ -498,27 +503,12 @@ func clientBudget(values tokens.Values) (budgetTable, []string) { "with the values above because they are the same class of fact, and a client " + "that has not been measured has not met one.", } - var reasons []string - for _, limit := range endingIn(values, "limit") { - if !strings.HasPrefix(limit, "budget.numbers.") { - continue - } - at := strings.TrimSuffix(limit, ".limit") - name := strings.TrimPrefix(at, "budget.numbers.") - unit, ok := values[at+".unit"] - if !ok { - reasons = append(reasons, fmt.Sprintf("%s carries a limit and no unit, so the number states nothing", at)) - continue - } - comparison, ok := values[at+".comparison"] - if !ok { - reasons = append(reasons, fmt.Sprintf("%s carries a limit and no comparison, so whether it is a ceiling or a value is not stated", at)) - continue - } + numbers, reasons := tokens.Numbers(values) + for _, n := range numbers { t.Rows = append(t.Rows, budgetRow{ - Name: name, - Limit: stated(comparison, values[limit], unit), - Means: strings.TrimSpace(values[at+".what"] + " " + values[at+".why"]), + Name: n.Name, + Limit: n.Stated(), + Means: strings.TrimSpace(n.What + " " + n.Why), }) } if len(t.Rows) == 0 { @@ -527,20 +517,6 @@ func clientBudget(values tokens.Values) (budgetTable, []string) { return t, reasons } -// stated turns a limit and its comparison into what the page says. The file -// carries the two apart, and a page printing the number alone would state a -// ceiling and a required value in the same words, which are opposite claims. -func stated(comparison, limit, unit string) string { - switch comparison { - case "below": - return "under " + limit + " " + unit - case "equal": - return "exactly " + limit + " " + unit - default: - return comparison + " " + limit + " " + unit - } -} - // siteBudget is what this page itself has to fit inside, read from the constants // the row that refuses a page reads. The published budget and the enforced one // are one set of numbers here, so the page cannot go on stating a limit nothing diff --git a/internal/site/designsystem_test.go b/internal/site/designsystem_test.go index 8da4e52..3b280d8 100644 --- a/internal/site/designsystem_test.go +++ b/internal/site/designsystem_test.go @@ -403,6 +403,46 @@ func TestThePageStatesBothBudgetsAndSaysWhichIsWhich(t *testing.T) { } } +// The budget line on the page and the number the copy carries are one value. +// This is the same statement the case above makes about a token, and it is worth +// making twice because the failure is not the same one: a colour rendered from +// somewhere other than the copy looks wrong to anybody who opens the page, and a +// latency ceiling rendered from somewhere else looks exactly like the right one. +func TestABudgetNumberChangedInTheCopyChangesExactlyThatLineOnThePage(t *testing.T) { + before := designPage(t, designTokens) + after := designPage(t, strings.Replace(designTokens, `"limit": 80,`, `"limit": 95,`, 1)) + + if !strings.Contains(before, "