| 1 | package guards |
| 2 | |
| 3 | import ( |
| 4 | "io/fs" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // maxJSFileLines is AGENTS.md's frontend cap: "300-line JS module cap, |
| 11 | // test-enforced, ratchet-down only. The vendored Jspreadsheet blob is |
| 12 | // exempt and named as such; nothing we write is." |
| 13 | const maxJSFileLines = 300 |
| 14 | |
| 15 | // jsLineCapExempt lists the files this cap does not apply to, by exact |
| 16 | // repo-relative path. |
| 17 | // |
| 18 | // By exact path, never by directory: an exemption for |
| 19 | // "static/vendor/**" would silently swallow the next file anybody drops |
| 20 | // in there, which is how a 300-line rule quietly becomes a rule about |
| 21 | // where you put your code. Adding an entry here is a visible decision |
| 22 | // in a diff. |
| 23 | // |
| 24 | // TestEveryJSExemptionIsLive asserts the walk below actually reaches |
| 25 | // each of these, so an entry can never be a comforting line that |
| 26 | // exempts nothing. |
| 27 | var jsLineCapExempt = map[string]bool{ |
| 28 | // AGENTS.md names this one explicitly. It does not currently bite: |
| 29 | // the published dist is minified to 17 lines, well under the cap. |
| 30 | // Kept anyway, so an upgrade to an unminified build is not a CI |
| 31 | // surprise. |
| 32 | "internal/sheets/static/vendor/jspreadsheet.js": true, |
| 33 | // This one bites: 13,601 lines. |
| 34 | "internal/sheets/static/vendor/jsuites.js": true, |
| 35 | } |
| 36 | |
| 37 | // skipJSDir is deliberately NOT repo_test.go's skipDir. |
| 38 | // |
| 39 | // skipDir skips any directory named "vendor", which is right for Go — |
| 40 | // it means a dependency tree nobody here wrote. It is exactly wrong |
| 41 | // here: our vendored JavaScript lives in a directory called vendor, so |
| 42 | // reusing skipDir made this test walk past the only files its |
| 43 | // exemption list names, pass, and prove nothing. It did precisely that |
| 44 | // when first written, and the exemptions read as load-bearing while |
| 45 | // being dead. Hence this function, and hence |
| 46 | // TestEveryJSExemptionIsLive. |
| 47 | func skipJSDir(name string) bool { |
| 48 | return strings.HasPrefix(name, ".") || name == "node_modules" |
| 49 | } |
| 50 | |
| 51 | // jsFiles returns the repo-relative path of every .js file. |
| 52 | func jsFiles(t *testing.T, root string) []string { |
| 53 | t.Helper() |
| 54 | var out []string |
| 55 | err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { |
| 56 | if err != nil { |
| 57 | return err |
| 58 | } |
| 59 | if d.IsDir() { |
| 60 | if skipJSDir(d.Name()) { |
| 61 | return filepath.SkipDir |
| 62 | } |
| 63 | return nil |
| 64 | } |
| 65 | if strings.HasSuffix(d.Name(), ".js") { |
| 66 | out = append(out, relPath(t, root, path)) |
| 67 | } |
| 68 | return nil |
| 69 | }) |
| 70 | if err != nil { |
| 71 | t.Fatalf("guards: walk: %v", err) |
| 72 | } |
| 73 | return out |
| 74 | } |
| 75 | |
| 76 | // TestJSFileLineCap enforces the cap on every .js file in the repo. |
| 77 | // |
| 78 | // It is the frontend twin of TestFileLineCap, kept separate rather than |
| 79 | // folded in as a mode because the two exemption lists have nothing in |
| 80 | // common: the Go list is empty and expected to stay that way, and this |
| 81 | // one is entirely third-party code. |
| 82 | func TestJSFileLineCap(t *testing.T) { |
| 83 | root := repoRoot(t) |
| 84 | files := jsFiles(t, root) |
| 85 | |
| 86 | // A cap that walks nothing passes. Asserted rather than assumed, |
| 87 | // because this test shipped once already having walked past |
| 88 | // everything its exemptions named. |
| 89 | if len(files) == 0 { |
| 90 | t.Fatal("guards: found no .js files at all; this test is not checking anything") |
| 91 | } |
| 92 | |
| 93 | for _, rel := range files { |
| 94 | if jsLineCapExempt[rel] { |
| 95 | continue |
| 96 | } |
| 97 | n, err := countLines(filepath.Join(root, rel)) |
| 98 | if err != nil { |
| 99 | t.Fatalf("guards: count lines in %s: %v", rel, err) |
| 100 | } |
| 101 | if n > maxJSFileLines { |
| 102 | t.Errorf("%s: %d lines exceeds the %d-line cap (AGENTS.md Frontend: 300-line JS module cap)", |
| 103 | rel, n, maxJSFileLines) |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // TestEveryJSExemptionIsLive proves each exemption names a file the cap |
| 109 | // actually reaches. |
| 110 | // |
| 111 | // This is the check that would have caught the bug above. An exemption |
| 112 | // for a file the walk never visits — deleted, moved, or behind a |
| 113 | // skipped directory — is worse than no exemption: it reads as "this |
| 114 | // file is allowed to be long" while granting nothing, and the next |
| 115 | // person to add a file at that path inherits permission nobody meant to |
| 116 | // give. |
| 117 | func TestEveryJSExemptionIsLive(t *testing.T) { |
| 118 | root := repoRoot(t) |
| 119 | seen := map[string]bool{} |
| 120 | for _, rel := range jsFiles(t, root) { |
| 121 | seen[rel] = true |
| 122 | } |
| 123 | for rel := range jsLineCapExempt { |
| 124 | if !seen[rel] { |
| 125 | t.Errorf("%s is exempt from the JS line cap, but the cap's own walk never reaches it: "+ |
| 126 | "the exemption grants nothing", rel) |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | |