package contratest import ( "bytes" "os" "path/filepath" "strings" "testing" ) // The JS module cap, from AGENTS.md: "small self-contained ES modules, no // build step, go:embed, 300 lines per module enforced by test, caps ratchet // down only." // // The cap is not tidiness. This is the code that holds the user's keys in the // one place we cannot sign or pin — a browser — and the only real defence is // that a person can read all of it. A 900-line module does not get read. const jsModuleCap = 300 // jsHighWater is the ratchet: each module's current length, and the most it // may ever be again. Shrinking a module means lowering its number here; // growing one is a conversation, not an edit. // // A module missing from this map fails the test rather than defaulting to the // cap — a new module in the crypto path is exactly the thing that should not // arrive without somebody noticing. var jsHighWater = map[string]int{ // The crypto core. "account.js": 172, "argon2id.js": 292, "blake2b.js": 99, "bytes.js": 61, "code.js": 165, "ecies.js": 132, "items.js": 124, "kdf.js": 129, "link.js": 131, "kit.js": 203, "net.js": 35, "p256.js": 107, "p256math.js": 143, "pdf.js": 180, "reissue.js": 78, "sharing.js": 153, "qr.js": 297, "qrgf.js": 59, "qrtables.js": 44, "sealed.js": 91, "vaultkeys.js": 217, // Page controllers. Thin on purpose: every decision that matters lives in // a crypto module, and these only move text between a form and one. "pages/passkey.js": 197, "pages/signin.js": 49, "pages/device.js": 264, "pages/devicescan.js": 96, "pages/kit.js": 156, "pages/signup.js": 258, // 222 -> 226 on 2026-09-05, deliberately: the open vault marks itself // when it belongs to somebody else, so a shared vault prints on // different stock. Four lines, one of them the statement. "pages/vault.js": 226, "pages/vaultshare.js": 139, } func TestJSModulesStaySmall(t *testing.T) { root := filepath.Join("..", "contra", "static") seen := map[string]bool{} // Every module this app wrote — the vendored rastrillo files in static/ // itself are the library's and are pinned by vendored_test.go instead. var names []string for _, sub := range []string{"crypto", "pages"} { entries, err := os.ReadDir(filepath.Join(root, sub)) if err != nil { t.Fatalf("read %s: %v", sub, err) } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".js") { continue } key := e.Name() if sub != "crypto" { key = sub + "/" + e.Name() } names = append(names, key) } } for _, name := range names { seen[name] = true rel := name if !strings.Contains(rel, "/") { rel = "crypto/" + rel } raw, err := os.ReadFile(filepath.Join(root, rel)) if err != nil { t.Errorf("read %s: %v", name, err) continue } lines := bytes.Count(raw, []byte("\n")) if !bytes.HasSuffix(raw, []byte("\n")) { lines++ } limit, declared := jsHighWater[name] if !declared { t.Errorf("%s is not in jsHighWater. A new module in the browser crypto "+ "path needs a deliberate line budget, not a default one — add it "+ "with its current length (%d).", name, lines) continue } if lines > jsModuleCap { t.Errorf("%s is %d lines, over the %d-line cap", name, lines, jsModuleCap) } if lines > limit { t.Errorf("%s grew from %d to %d lines. Caps ratchet down only: split it, "+ "or make the case for a bigger budget.", name, limit, lines) } if lines < limit { t.Errorf("%s shrank from %d to %d lines — good. Lower its jsHighWater "+ "entry to %d so the ratchet keeps the ground you gained.", name, limit, lines, lines) } } for name := range jsHighWater { if !seen[name] { t.Errorf("jsHighWater lists %s, which no longer exists", name) } } }