|
1 | | -// Two import rules that types alone cannot enforce. Each one is a property a |
| 1 | +// Three source rules that types alone cannot enforce. Each one is a property a |
2 | 2 | // consumer would feel and no other step in the build would notice. |
3 | 3 | // |
4 | 4 | // 1. Nothing imports from @workbench/*. corbits-ui is a clean rewrite; a single |
|
12 | 12 | // Only Node and Vite surface it; a Turbopack consumer builds cleanly, so |
13 | 13 | // this cannot be left to whichever bundler the person testing happened to |
14 | 14 | // use. |
15 | | -import { readdirSync, readFileSync, statSync } from "node:fs"; |
| 15 | +// |
| 16 | +// 3. A `ui/` or `blocks/` component starts with "use client" exactly when it |
| 17 | +// calls a hook, creates a context, defines an inline event handler, or |
| 18 | +// imports an optional peer. Missing, it breaks React Server Components |
| 19 | +// consumers; extra, it makes a stateless component client-only for no |
| 20 | +// reason. After a build, the emitted dist/ module must keep the directive. |
| 21 | +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; |
16 | 22 | import { dirname, join, relative, resolve } from "node:path"; |
17 | 23 |
|
18 | 24 | const ROOT = new URL("../src", import.meta.url).pathname; |
@@ -85,11 +91,37 @@ for (const path of reachableFrom(barrel)) { |
85 | 91 | } |
86 | 92 | } |
87 | 93 |
|
| 94 | +// --- Rule 3: client boundaries ------------------------------------------------ |
| 95 | + |
| 96 | +const CLIENT_ONLY = new RegExp( |
| 97 | + [ |
| 98 | + String.raw`\buse[A-Z]\w*\s*[<(]`, |
| 99 | + "createContext", |
| 100 | + String.raw`\bon[A-Z]\w*=\{\s*(\(|\w+\s*=>)`, |
| 101 | + `["'](${OPTIONAL_PEERS.map((peer) => peer.replace("/", "\\/")).join("|")})["']`, |
| 102 | + ].join("|"), |
| 103 | +); |
| 104 | +const DIST = new URL("../dist", import.meta.url).pathname; |
| 105 | +const stripComments = (source) => source.replace(/\/\*[\s\S]*?\*\/|(^|[^:])\/\/.*$/gm, "$1"); |
| 106 | + |
| 107 | +for (const path of sources) { |
| 108 | + if (!/^(ui|blocks)\//.test(id(path)) || /\.test\.tsx?$/.test(path)) continue; |
| 109 | + const source = read(path); |
| 110 | + const marked = source.startsWith('"use client";'); |
| 111 | + if (CLIENT_ONLY.test(stripComments(source)) !== marked) { |
| 112 | + violations.push(`${id(path)}: ${marked ? "stateless but marked" : "stateful but missing"} "use client"`); |
| 113 | + } |
| 114 | + const built = join(DIST, id(path).replace(/\.tsx?$/, ".js")); |
| 115 | + if (marked && existsSync(built) && !read(built).startsWith('"use client";')) { |
| 116 | + violations.push(`${id(path)}: the build dropped "use client" from ${relative(DIST, built)}`); |
| 117 | + } |
| 118 | +} |
| 119 | + |
88 | 120 | if (violations.length > 0) { |
89 | 121 | console.error(`dep-guard: forbidden imports\n${[...new Set(violations)].join("\n")}`); |
90 | 122 | process.exit(1); |
91 | 123 | } |
92 | 124 | console.log( |
93 | 125 | `dep-guard: clean (${RULES.map((rule) => rule.label).join(", ")}; ` + |
94 | | - `root barrel free of ${OPTIONAL_PEERS.join(", ")})`, |
| 126 | + `root barrel free of ${OPTIONAL_PEERS.join(", ")}; client boundaries match)`, |
95 | 127 | ); |
0 commit comments