cursedbelt 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/react/components/Autocomplete.d.ts.map +1 -1
  2. package/dist/react/components/Autocomplete.js +1 -1
  3. package/dist/react/components/Autocomplete.js.map +1 -1
  4. package/dist/react/components/ComboboxField.d.ts.map +1 -1
  5. package/dist/react/components/ComboboxField.js +1 -1
  6. package/dist/react/components/ComboboxField.js.map +1 -1
  7. package/dist/react/media-gallery/MediaGallery.d.ts +9 -2
  8. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  9. package/dist/react/media-gallery/MediaGallery.js +12 -4
  10. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  11. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  12. package/dist/styles-areas/analytics.css +1 -1
  13. package/dist/styles-areas/data-table.css +1 -1
  14. package/dist/styles-areas/fields.css +1 -1
  15. package/dist/styles-areas/media-gallery.css +1 -1
  16. package/dist/styles-areas/wizard.css +1 -1
  17. package/package.json +50 -6
  18. package/scripts/demoServer.ts +211 -0
  19. package/scripts/gate.ts +101 -0
  20. package/scripts/guardrailsEnforce.spec.ts +69 -0
  21. package/scripts/guardrailsEnforce.ts +59 -19
  22. package/scripts/paths.ts +1 -1
  23. package/src/demoFixture.spec.ts +54 -10
  24. package/src/demoStaticServer.spec.ts +169 -0
  25. package/src/publishShape.spec.ts +79 -0
  26. package/src/react/components/Autocomplete.tsx +11 -1
  27. package/src/react/components/ComboboxField.spec.tsx +12 -0
  28. package/src/react/components/ComboboxField.tsx +11 -1
  29. package/src/react/media-gallery/MediaGallery.spec.tsx +23 -0
  30. package/src/react/media-gallery/MediaGallery.tsx +26 -9
  31. package/src/styles-areas/analytics.css +1 -1
  32. package/src/styles-areas/data-table.css +1 -1
  33. package/src/styles-areas/fields.css +1 -1
  34. package/src/styles-areas/media-gallery.css +1 -1
  35. package/src/styles-areas/wizard.css +1 -1
  36. package/src/testFilesRunInParallel.spec.ts +3 -3
  37. package/src/typecheckCachesAreSeparate.spec.ts +2 -2
  38. package/src/verifyGraph.spec.ts +75 -108
  39. package/scripts/verify.ts +0 -296
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -31,10 +31,10 @@
31
31
  "styles": "bun run styles:static && bun run styles:utilities && bun run styles:areas",
32
32
  "styles:static": "bun run scripts/generateStaticStyles.ts",
33
33
  "styles:utilities": "bun run scripts/generateUtilityStyles.ts",
34
- "test": "NODE_ENV=development bun test src --timeout 60000 --parallel=6",
34
+ "test": "NODE_ENV=development bun test src scripts --timeout 60000 --parallel=6",
35
35
  "demo": "vite --config demo/vite.config.ts",
36
36
  "demo:build": "vite build --config demo/vite.config.ts",
37
- "demo:serve": "bun run demo:build && vite preview --config demo/vite.config.ts",
37
+ "demo:serve": "bun run demo:build && bun run scripts/demoServer.ts",
38
38
  "build": "rm -rf dist.next dist.prev && bun run scripts/generateStaticStyles.ts && bun run scripts/generateUtilityStyles.ts && bun run scripts/generateAreaStyles.ts && tsc --project tsconfig.build.json --outDir dist.next && tsc --project tsconfig.scripts.build.json --outDir dist.next/scripts && CURSEDBELT_DIST_DIR=dist.next bun run scripts/copyDistAssets.ts && CURSEDBELT_DIST_DIR=dist.next bun run scripts/checkDistExports.ts &&{ [ -d dist ] && mv dist dist.prev || true; } && mv dist.next dist && rm -rf dist.prev",
39
39
  "e2e": "playwright test --config e2e/playwright.config.ts",
40
40
  "e2e:filter-rail": "playwright test --config e2e/playwright.config.ts filter-rail.spec.ts",
@@ -47,9 +47,10 @@
47
47
  "e2e:touch": "playwright test --config e2e/playwright.config.ts --project=touch",
48
48
  "done:serial": "bun run typecheck && bun run lint && bun run test && bun run build",
49
49
  "paths": "bun run scripts/paths.ts",
50
- "verify": "bun run scripts/verify.ts",
51
- "prepublishOnly": "bun run verify",
52
- "styles:areas": "bun run scripts/generateAreaStyles.ts"
50
+ "verify": "bun run gate",
51
+ "prepublishOnly": "bun run gate --force",
52
+ "styles:areas": "bun run scripts/generateAreaStyles.ts",
53
+ "gate": "bun run scripts/gate.ts"
53
54
  },
54
55
  "exports": {
55
56
  ".": {
@@ -687,5 +688,48 @@
687
688
  "repository": {
688
689
  "type": "git",
689
690
  "url": "git+https://github.com/curtwphillips/cursedbelt.git"
691
+ },
692
+ "gate": {
693
+ "stages": [
694
+ {
695
+ "script": "paths"
696
+ },
697
+ {
698
+ "script": "typecheck:root"
699
+ },
700
+ {
701
+ "script": "typecheck:specs"
702
+ },
703
+ {
704
+ "script": "typecheck:scripts"
705
+ },
706
+ {
707
+ "script": "demo:check"
708
+ },
709
+ {
710
+ "script": "styles"
711
+ },
712
+ {
713
+ "script": "build",
714
+ "needs": [
715
+ "styles"
716
+ ],
717
+ "why": "its first two steps are the same generators; two writers on one stylesheet is a race"
718
+ },
719
+ {
720
+ "script": "test",
721
+ "needs": [
722
+ "styles"
723
+ ],
724
+ "why": "stylesStaticMatches / stylesUtilitiesMatches read those two files and assert they are current"
725
+ },
726
+ {
727
+ "script": "e2e",
728
+ "needs": [
729
+ "styles"
730
+ ],
731
+ "why": "the demo bundle it drives is compiled from the same stylesheets"
732
+ }
733
+ ]
690
734
  }
691
735
  }
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The static server `bun run e2e` drives — the built demo, served by code this repo owns.
4
+ *
5
+ * ## Why this exists instead of `vite preview`
6
+ *
7
+ * Measured 2026-09-18 (task 388): `bun run verify` went red about one run in four on an
8
+ * unmodified tree, always the same way — `e2e` failing at exactly `webServer.timeout`
9
+ * (120.1s) with all 138 specs marked failed, and the real cause 150 lines below the noise:
10
+ *
11
+ * [WebServer] error: write after end
12
+ * [WebServer] code: "ERR_STREAM_WRITE_AFTER_END"
13
+ * [WebServer] at advanceResponsePipeline (node:_http_server:1343:38)
14
+ * [WebServer] error: script "demo:serve" exited with code 1
15
+ *
16
+ * That throw comes out of Bun 1.4.2's `node:http` compatibility layer, *after* the connect
17
+ * handler `vite preview` installed has returned — so no middleware, and no `server.on`
18
+ * listener on the connect app, is positioned to catch it. It fires when the peer closes a
19
+ * socket the response is still being written to, which is the normal state of affairs with
20
+ * eight Playwright workers: every navigation supersedes its own in-flight subresource
21
+ * requests, and `page.goto` during teardown abandons whatever was in flight.
22
+ *
23
+ * `Bun.serve` is a different layer. A handler returns a `Response`; a peer that hangs up
24
+ * cancels the body stream, and there is no socket write for user code to get wrong. The
25
+ * `error` hook below is the only place a fault can surface, and it answers with a 500
26
+ * instead of exiting.
27
+ *
28
+ * 🔴 This is deliberately the dumbest static server that can serve the fixture, because
29
+ * every feature it does not have is a way it cannot die halfway through a gate. No
30
+ * compression, no range requests, no directory listing, no watch mode, no HMR. `bun run
31
+ * demo` is still vite's dev server — a human editing a primitive wants the HMR, and that
32
+ * process has one tab attached to it rather than eight aborting workers.
33
+ */
34
+ import { statSync } from 'node:fs';
35
+ import { extname, resolve, sep } from 'node:path';
36
+ import { DEMO_DIST_DIR, DEMO_HOST, DEMO_PORT, DEMO_URL } from '../demo/devServer';
37
+
38
+ /**
39
+ * 🔴 Never cached, by anything. The gate's whole claim is that it tested the bytes in the
40
+ * tree; a 304 from a browser profile that outlived the build would quietly break that, and
41
+ * it would break it in the direction that reads green.
42
+ */
43
+ const NO_STORE = { 'cache-control': 'no-store, max-age=0' } as const;
44
+
45
+ /**
46
+ * The request path a URL resolves to inside `root`, or `null` for anything that is not a
47
+ * file under it.
48
+ *
49
+ * Pure and exported so the traversal refusal is provable without a socket.
50
+ */
51
+ export function resolveRequestPath(root: string, pathname: string): string | null {
52
+ let decoded: string;
53
+ try {
54
+ decoded = decodeURIComponent(pathname);
55
+ } catch {
56
+ // A malformed `%` escape. Not a path, and not worth guessing at.
57
+ return null;
58
+ }
59
+ if (decoded.includes('\0')) return null;
60
+ const wanted = decoded === '' || decoded === '/' ? '/index.html' : decoded;
61
+ // `resolve` collapses `..` itself; the prefix check below is what makes that a
62
+ // guarantee rather than a belief about how many segments the caller sent.
63
+ const candidates = [resolve(root, `.${wanted}`)];
64
+ // The fixture pages are a multi-page BUILD, so `/table-layout` and `/table-layout.html`
65
+ // are the same page. vite preview resolved both; specs use the explicit form, and a
66
+ // silent 404 on the bare one would be a difference nobody would notice until it bit.
67
+ if (extname(wanted) === '') candidates.push(resolve(root, `.${wanted}.html`));
68
+
69
+ const fence = root.endsWith(sep) ? root : root + sep;
70
+ for (const candidate of candidates) {
71
+ if (candidate !== root && !candidate.startsWith(fence)) continue;
72
+ try {
73
+ if (statSync(candidate).isFile()) return candidate;
74
+ } catch {
75
+ // Missing, or a directory we will not list. Try the next shape.
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Faults that are the PEER's doing, not ours: a client that went away mid-response.
83
+ *
84
+ * 🔴 The allow-list is closed on purpose. A blanket `uncaughtException` handler on a test
85
+ * fixture is how a server keeps serving after it has lost the thing it was serving, and
86
+ * the gate then measures a corpse that answers 200. Anything not named here still kills
87
+ * the process, loudly, which is the behaviour every other fault should have.
88
+ */
89
+ const PEER_HANGUP_CODES: ReadonlySet<string> = new Set([
90
+ 'ECONNRESET',
91
+ 'ECONNABORTED',
92
+ 'EPIPE',
93
+ 'ERR_STREAM_WRITE_AFTER_END',
94
+ 'ERR_STREAM_DESTROYED',
95
+ 'ERR_STREAM_PREMATURE_CLOSE',
96
+ 'ABORT_ERR',
97
+ ]);
98
+
99
+ /** Whether a thrown value is a peer hang-up rather than a fault in this server. */
100
+ export function isPeerHangup(thrown: unknown): boolean {
101
+ if (typeof thrown !== 'object' || thrown === null) return false;
102
+ const code = (thrown as { code?: unknown }).code;
103
+ if (typeof code === 'string' && PEER_HANGUP_CODES.has(code)) return true;
104
+ const name = (thrown as { name?: unknown }).name;
105
+ return name === 'AbortError';
106
+ }
107
+
108
+ export interface DemoServerOptions {
109
+ /** The directory to serve. Defaults to the built demo. */
110
+ root?: string;
111
+ /** Defaults to `DEMO_PORT`; `0` takes an ephemeral one, which is what the spec uses. */
112
+ port?: number;
113
+ hostname?: string;
114
+ }
115
+
116
+ export function startDemoServer(options: DemoServerOptions = {}): Bun.Server<undefined> {
117
+ const root = options.root ?? DEMO_DIST_DIR;
118
+ return Bun.serve({
119
+ port: options.port ?? DEMO_PORT,
120
+ // 🔴 One literal address, never the name — `demo/devServer.ts` has the measurement.
121
+ // Binding `localhost` let two processes hold 4318 at once, on `::1` and `127.0.0.1`,
122
+ // with no collision between them.
123
+ hostname: options.hostname ?? DEMO_HOST,
124
+ // 🔴 EXPLICIT, and the single least obvious line in this file. Bun.serve defaults to
125
+ // SO_REUSEPORT: measured 2026-09-18 on Bun 1.4.2, a second process binding a port
126
+ // another one already holds SUCCEEDS, prints a ready line, and serves nothing —
127
+ // every request is still answered by the first. That is `strictPort: false` with the
128
+ // failure hidden one layer deeper, and it would have made this whole change worse
129
+ // than what it replaced: the gate would build the demo, "serve" it, and then test
130
+ // whatever stale bundle a stray `bun run demo` was holding. With this, the bind
131
+ // throws EADDRINUSE and `main()` says so in one line.
132
+ reusePort: false,
133
+ development: false,
134
+ // Long enough for the heaviest chunk on a loaded machine; short enough that a
135
+ // wedged connection cannot hold a gate open. Bun's default is 10s.
136
+ idleTimeout: 60,
137
+ fetch(request) {
138
+ const file = resolveRequestPath(root, new URL(request.url).pathname);
139
+ if (!file) return new Response('not found\n', { status: 404, headers: NO_STORE });
140
+ return new Response(Bun.file(file), { headers: NO_STORE });
141
+ },
142
+ error(thrown) {
143
+ // The one seam a fault can reach. Returning a Response keeps the process alive;
144
+ // this is the hook `vite preview` had no equivalent of.
145
+ if (!isPeerHangup(thrown)) {
146
+ process.stderr.write(`demo server: request failed — ${thrown instanceof Error ? thrown.stack : String(thrown)}\n`);
147
+ }
148
+ return new Response('demo server error\n', { status: 500, headers: NO_STORE });
149
+ },
150
+ });
151
+ }
152
+
153
+ /**
154
+ * 🔴 Both halves matter. Exiting non-zero the moment the bundle or the port is wrong is
155
+ * what turns "Playwright waited 120s and then failed 138 specs" into one line a reader
156
+ * believes — and `$FORGE/tools/gate.ts` lifts these exact words to the top of a red gate.
157
+ */
158
+ async function main(): Promise<void> {
159
+ try {
160
+ if (!statSync(DEMO_DIST_DIR).isDirectory()) throw new Error('not a directory');
161
+ } catch {
162
+ process.stderr.write(
163
+ `demo server: no built demo at ${DEMO_DIST_DIR} — run \`bun run demo:build\` first.\n` +
164
+ '`bun run demo:serve` does both, and that is what the e2e webServer runs.\n',
165
+ );
166
+ process.exit(1);
167
+ }
168
+
169
+ let server: Bun.Server<undefined>;
170
+ try {
171
+ server = startDemoServer();
172
+ } catch (thrown) {
173
+ const code = (thrown as { code?: string } | null)?.code;
174
+ if (code === 'EADDRINUSE') {
175
+ process.stderr.write(
176
+ `demo server: EADDRINUSE — port ${DEMO_PORT} is already taken, so the gate cannot serve ` +
177
+ 'the demo it just built.\nSomething else is listening (a stray `bun run demo`, or a ' +
178
+ 'previous run that outlived its Playwright). Stop it and run the gate again.\n',
179
+ );
180
+ process.exit(1);
181
+ }
182
+ throw thrown;
183
+ }
184
+
185
+ // 🔴 Ask ourselves, over the socket, on the URL the specs are pointed at — before
186
+ // Playwright starts a 120-second wait it cannot explain. This catches the shapes a
187
+ // successful `bind` does not: a `localhost` that resolved to an address the client will
188
+ // not use, and a `dist` that built but emitted no `index.html`.
189
+ try {
190
+ const probe = await fetch(DEMO_URL, { signal: AbortSignal.timeout(5_000) });
191
+ if (!probe.ok) throw new Error(`${DEMO_URL} answered ${probe.status}`);
192
+ } catch (thrown) {
193
+ process.stderr.write(
194
+ `demo server: bound ${DEMO_PORT}, but ${DEMO_URL} does not answer — ` +
195
+ `${thrown instanceof Error ? thrown.message : String(thrown)}\n` +
196
+ `Nothing can drive this. Check that \`bun run demo:build\` wrote ${DEMO_DIST_DIR}/index.html.\n`,
197
+ );
198
+ await server.stop(true);
199
+ process.exit(1);
200
+ }
201
+
202
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
203
+ process.on(signal, () => {
204
+ void server.stop(true);
205
+ process.exit(0);
206
+ });
207
+ }
208
+ process.stdout.write(`demo server: serving ${DEMO_DIST_DIR} at ${DEMO_URL}\n`);
209
+ }
210
+
211
+ if (import.meta.main) await main();
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `bun run gate` — this repo's gate, run by the generation's shared runner.
3
+ *
4
+ * ## Where the argument went
5
+ *
6
+ * This repo's gate stopped being an `&&` chain on 2026-09-15 and became a dependency graph, and
7
+ * `src/verifyGraph.spec.ts` is the file that holds it to that — the stage set it may never
8
+ * shrink below, the two edges, and why `build` is a leaf. On 2026-09-20 the SCHEDULER moved to
9
+ * `$FORGE/tools/gate.ts` so the other twenty repos could have it too, and the stages moved to the
10
+ * `gate` block in `package.json`, which keeps `package.json` the one place every command is
11
+ * spelled. Read those two; this file only locates the tool.
12
+ *
13
+ * What the move buys this repo specifically is the SECOND run, not the first. One gate here is
14
+ * 33–65 s and has been since 2026-09-18 (the graph did that; `autopilot timings`' all-time median
15
+ * of 6.5 m is dominated by runs from before it, and is the wrong number to plan against). What
16
+ * this repo spends is 14.72 h — 58 % of every gate second this generation has ever spent, over 84
17
+ * runs across 41 agents — because the ledger says the median agent runs the gate TWICE and the
18
+ * supervisor runs it again after, on the same tree. A stamped run is 0.1 s. See
19
+ * `$FORGE/tools/gate.ts` for why the stamp is keyed on content and not on a clock.
20
+ *
21
+ * ## 🔴 Why this is a wrapper and not `bun ../../tools/gate.ts .`
22
+ *
23
+ * That spelling assumes this checkout sits at a fixed depth under the generation root, and a
24
+ * WORKTREE never does. It is the same argument, verbatim, as `scripts/paths.ts` — which is where
25
+ * it is written down, along with what it cost on 2026-09-17 — and the same two walks: up for
26
+ * `forge.env`, then via the primary checkout a worktree's `.git` file points at.
27
+ *
28
+ * 🔴 **No generation, no pass.** A gate that could not be located has not passed.
29
+ */
30
+ import { spawnSync } from "node:child_process";
31
+ import { existsSync } from "node:fs";
32
+ import { dirname, join, resolve } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ /** The one tool: the shared runner. The stages it runs are the `gate` block in `package.json`. */
36
+ const TOOLS = ["gate.ts"];
37
+
38
+ /** How far up either walk looks before giving up — a bound, so a misuse is `null` in ms. */
39
+ const MAX_HOPS = 16;
40
+
41
+ /** Walk up from `from` for a directory containing `marker`, or `null`. */
42
+ function walkUp(from: string, marker: string): string | null {
43
+ let dir = resolve(from);
44
+ for (let hops = 0; hops < MAX_HOPS; hops++) {
45
+ if (existsSync(join(dir, marker))) return dir;
46
+ const up = dirname(dir);
47
+ if (up === dir) return null;
48
+ dir = up;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /** The directory holding `forge.env` above `from`, or `null` when that is not inside a generation. */
54
+ function findForgeRoot(from: string): string | null {
55
+ return walkUp(from, "forge.env");
56
+ }
57
+
58
+ /** The generation above the checkout this WORKTREE was cut from, or `null` on anything unexpected. */
59
+ function viaWorktree(from: string): string | null {
60
+ try {
61
+ const git = spawnSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
62
+ cwd: from,
63
+ encoding: "utf8",
64
+ timeout: 5_000,
65
+ });
66
+ if (git.status !== 0) return null;
67
+ const gitDir = (git.stdout ?? "").trim();
68
+ if (!gitDir || !gitDir.startsWith("/")) return null;
69
+ // `<primary>/.git` → `<primary>`, then the ordinary walk up.
70
+ return findForgeRoot(dirname(gitDir));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ const here = dirname(fileURLToPath(import.meta.url));
77
+ /** This checkout — the nearest ancestor holding a `package.json`, so it is right in a worktree. */
78
+ const repo = walkUp(here, "package.json");
79
+ const root = findForgeRoot(here) ?? viaWorktree(here);
80
+
81
+ if (!repo) {
82
+ console.error(`✗ no package.json above ${here}, so there is no repo to check.`);
83
+ process.exit(1);
84
+ }
85
+ if (!root) {
86
+ console.error("✗ this checkout is not inside a generation and is not a worktree of one, so");
87
+ console.error(` tools/{${TOOLS.join(",")}} cannot be located. Run it from a checkout under a`);
88
+ console.error(" generation, or from a worktree cut from one — a skipped check is not a passed one.");
89
+ process.exit(1);
90
+ }
91
+
92
+ for (const name of TOOLS) {
93
+ const tool = join(root, "tools", name);
94
+ if (!existsSync(tool)) {
95
+ console.error(`✗ ${tool} does not exist. The generation at ${root} has no ${name},`);
96
+ console.error(" so this repo cannot prove it obeys that law. That is a failure, not a skip.");
97
+ process.exit(1);
98
+ }
99
+ const result = spawnSync("bun", [tool, repo, ...process.argv.slice(2)], { stdio: "inherit" });
100
+ if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
101
+ }
@@ -1,3 +1,8 @@
1
+ // 🔴 `package.json`'s `test` script globs `src scripts`, not `src` — this file is the only
2
+ // spec outside `src`, and until 2026-09-18 nothing ran it. The gate was green while every
3
+ // assertion in here (129 of them, the scanner's entire behavioural contract) was inert, so a
4
+ // rule could rot and `bun run verify` would still say ok. If you narrow that glob back, the
5
+ // tests below stop being checks and become a document.
1
6
  import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
7
  import { tmpdir } from 'node:os';
3
8
  import { join } from 'node:path';
@@ -107,6 +112,70 @@ describe('opening-tag extraction (comment-aware)', () => {
107
112
  });
108
113
  });
109
114
 
115
+ // Filed 2026-09-18, found while proving the fix above: the first negative test came back
116
+ // green for the WRONG reason. Making the walker comment-aware widened what it returns, and
117
+ // the opt-out checks then read prose with regexes that look for a real ATTRIBUTE — so a
118
+ // comment merely QUOTING `role="radio"` suppressed no-raw-action-button on a raw button
119
+ // that had no presentational role at all. The rule could not be trusted to read zero in
120
+ // exactly the well-documented files it should be strictest about. The input was wrong, not
121
+ // the pattern: the comment check reads the tag as written, the attribute check reads it
122
+ // comment-blanked.
123
+ describe('a presentational marker must be an ATTRIBUTE, not prose about one', () => {
124
+ test('role="radio" only inside a comment does NOT opt the button out', () => {
125
+ const source = `<button
126
+ // A radio-like list: \`role="radio"\` + \`aria-checked\` says so to a screen reader.
127
+ type="button"
128
+ onClick={() => onPick(id)}
129
+ >Move here</button>`;
130
+ expect(check('no-raw-action-button', source)).toHaveLength(1);
131
+ });
132
+
133
+ test('aria-selected= only inside a block comment does NOT opt the button out', () => {
134
+ const source = `<button
135
+ /* The row is the control here; aria-selected= would be the tab-ish spelling. */
136
+ type="button"
137
+ >Move here</button>`;
138
+ expect(check('no-raw-action-button', source)).toHaveLength(1);
139
+ });
140
+
141
+ test('the guardrails-ignore comment still opts out beside that same prose', () => {
142
+ const source = `<button
143
+ // A radio-like list: \`role="radio"\` + \`aria-checked\` says so to a screen reader.
144
+ // guardrails-ignore no-raw-action-button: presentational row in a radio-like list
145
+ type="button"
146
+ >Move here</button>`;
147
+ expect(check('no-raw-action-button', source)).toHaveLength(0);
148
+ });
149
+
150
+ test('the REAL attributes still pass silently, prose and all', () => {
151
+ const source = `<button
152
+ // A radio-like list: \`role="radio"\` + \`aria-checked\` says so to a screen reader.
153
+ role="radio"
154
+ aria-checked={selected}
155
+ type="button"
156
+ >Move here</button>`;
157
+ expect(check('no-raw-action-button', source)).toHaveLength(0);
158
+ });
159
+
160
+ // The same shape on no-raw-title-attr, which fires the other way: there the attribute
161
+ // regex is what CREATES the violation, so prose mentioning `title=` invented one.
162
+ test('a title= written only in a comment is not a title attribute', () => {
163
+ const source = `<span
164
+ // Not a title= hover hint — the expansion is rendered beside it.
165
+ className="tabular"
166
+ >UTC</span>`;
167
+ expect(check('no-raw-title-attr', source)).toHaveLength(0);
168
+ });
169
+
170
+ test('a real title attribute below that prose is still flagged', () => {
171
+ const source = `<span
172
+ // Reads as a title= hover hint, and that is the bug.
173
+ title="Coordinated Universal Time"
174
+ >UTC</span>`;
175
+ expect(check('no-raw-title-attr', source)).toHaveLength(1);
176
+ });
177
+ });
178
+
110
179
  describe('no-raw-overflow', () => {
111
180
  test('flags inline style overflow: auto', () => {
112
181
  expect(check('no-raw-overflow', '<div style={{ overflow: "auto" }} />')).toHaveLength(1);
@@ -258,7 +258,24 @@ function skipQuoted(source: string, start: number): number {
258
258
  return i;
259
259
  }
260
260
 
261
- /** Slice from a `<button` (or similar) match to the end of its opening tag, tolerating
261
+ /** One opening tag, in the two forms its checks need. Both strings cover the SAME span
262
+ * and have the same length, so a line number or offset taken from one is valid in the
263
+ * other. */
264
+ interface OpeningTag {
265
+ /** Exactly as written, comments included — what a `guardrails-ignore <rule>:` check
266
+ * must read, since the thing it is looking for IS a comment. */
267
+ readonly text: string;
268
+ /** The same span with comment bodies blanked to spaces (newlines kept, so offsets and
269
+ * line numbers hold) — what an ATTRIBUTE check must read. An attribute check run over
270
+ * `text` is fooled by prose that merely QUOTES the attribute: a comment reading
271
+ * ``role="radio" + aria-checked says so to a screen reader`` matched
272
+ * `PRESENTATIONAL_MARKER_RE` and silently opted a raw `<button>` out of
273
+ * no-raw-action-button (task 42). Narrowing such a regex only moves the goalposts —
274
+ * the input was wrong, so the input is what changed. */
275
+ readonly code: string;
276
+ }
277
+
278
+ /** Read from a `<button` (or similar) match to the end of its opening tag, tolerating
262
279
  * `{...}` JS expressions in attribute values that may themselves contain `>`
263
280
  * (`onClick={() => …}`, which works because that `>` sits inside braces).
264
281
  *
@@ -266,30 +283,40 @@ function skipQuoted(source: string, start: number): number {
266
283
  * so it may contain an angle bracket ("a radio-like list rather than a `<select>`").
267
284
  * Those are skipped the same way `blankComments` skips them, because a `>` written in prose
268
285
  * used to end the tag here and make every attribute below it invisible — the
269
- * `guardrails-ignore` opt-out included. `blankComments` cannot simply be run first: the
270
- * opt-out checks read this slice and must see the ORIGINAL comment text (see the
271
- * no-raw-action-button check below), so the skip only suppresses TERMINATION; the comment
272
- * stays in what is returned. */
273
- function extractOpeningTag(source: string, startIndex: number): string {
286
+ * `guardrails-ignore` opt-out included. The skip therefore suppresses only TERMINATION;
287
+ * the comment text survives in `.text` for the opt-out checks, and is blanked in `.code`
288
+ * for the attribute checks. */
289
+ function readOpeningTag(source: string, startIndex: number): OpeningTag {
290
+ const blank = (span: string) => span.replaceAll(/[^\n]/g, " ");
274
291
  let i = startIndex;
275
292
  let braceDepth = 0;
293
+ let code = "";
276
294
  while (i < source.length) {
277
295
  const two = source.slice(i, i + 2);
278
296
  if (two === "//") {
279
297
  const end = source.indexOf("\n", i);
280
- i = end === -1 ? source.length : end;
298
+ const stop = end === -1 ? source.length : end;
299
+ code += blank(source.slice(i, stop));
300
+ i = stop;
281
301
  continue;
282
302
  }
283
303
  if (two === "/*") {
284
304
  const end = source.indexOf("*/", i + 2);
285
- i = end === -1 ? source.length : end + 2;
305
+ const stop = end === -1 ? source.length : end + 2;
306
+ code += blank(source.slice(i, stop));
307
+ i = stop;
286
308
  continue;
287
309
  }
288
310
  const ch = source[i];
289
311
  if (ch === '"' || ch === "'" || ch === "`") {
290
- i = skipQuoted(source, i);
312
+ // A quoted attribute VALUE is real markup, not prose — `role="radio"` must
313
+ // survive into `.code`, which is the whole point of the attribute checks.
314
+ const stop = skipQuoted(source, i);
315
+ code += source.slice(i, stop);
316
+ i = stop;
291
317
  continue;
292
318
  }
319
+ code += ch;
293
320
  if (ch === "{") braceDepth++;
294
321
  else if (ch === "}") braceDepth--;
295
322
  else if (ch === ">" && braceDepth <= 0) {
@@ -298,7 +325,7 @@ function extractOpeningTag(source: string, startIndex: number): string {
298
325
  }
299
326
  i++;
300
327
  }
301
- return source.slice(startIndex, i);
328
+ return { text: source.slice(startIndex, i), code };
302
329
  }
303
330
 
304
331
  function lineOf(source: string, index: number): number {
@@ -445,10 +472,13 @@ const noRawActionButton: Rule = {
445
472
  const scannable = blankNonMarkupText(source);
446
473
  RAW_BUTTON_RE.lastIndex = 0;
447
474
  for (const match of scannable.matchAll(RAW_BUTTON_RE)) {
448
- // Re-extract the tag from the ORIGINAL source so opt-out comments (which were
449
- // just blanked out of `scannable`) are still visible to the ignore checks.
450
- const tag = extractOpeningTag(source, match.index);
451
- if (IGNORE_COMMENT_RE.test(tag) || PRESENTATIONAL_MARKER_RE.test(tag)) continue;
475
+ // Re-read the tag from the ORIGINAL source so opt-out comments (which were
476
+ // just blanked out of `scannable`) are still visible to the ignore check.
477
+ // The two halves take DIFFERENT inputs, and that is the point: the ignore
478
+ // check is looking for a comment, the marker check for a real attribute, so
479
+ // prose quoting `role="radio"` opts nothing out.
480
+ const tag = readOpeningTag(source, match.index);
481
+ if (IGNORE_COMMENT_RE.test(tag.text) || PRESENTATIONAL_MARKER_RE.test(tag.code)) continue;
452
482
  const line = lineOf(source, match.index);
453
483
  violations.push({
454
484
  file: filePath,
@@ -510,9 +540,10 @@ const noRawInputs: Rule = {
510
540
  const scannable = blankNonMarkupText(source);
511
541
  RAW_INPUT_RE.lastIndex = 0;
512
542
  for (const match of scannable.matchAll(RAW_INPUT_RE)) {
513
- const tag = extractOpeningTag(source, match.index);
543
+ const tag = readOpeningTag(source, match.index);
514
544
  const ignoreRe = /guardrails-ignore\s+no-raw-inputs\s*:/;
515
- if (ignoreRe.test(tag)) continue;
545
+ // `.text`: this rule's only opt-out IS a comment, so it reads the comments.
546
+ if (ignoreRe.test(tag.text)) continue;
516
547
  const line = lineOf(source, match.index);
517
548
  violations.push({
518
549
  file: filePath,
@@ -838,6 +869,12 @@ const ISDIRTY_PROP_RE = /\bisDirty\s*=/;
838
869
  * primitive. The `(?<!\[)` matters — `body().querySelector('[role="dialog"]')`
839
870
  * contains the same characters and is a test READING a dialog, not declaring one;
840
871
  * without it every dialog spec in the fleet is a violation.
872
+ *
873
+ * 🔴 Run it over COMMENT-BLANKED source only (`blankComments`, as `check` does below),
874
+ * never over raw text. It is an attribute regex, so prose quoting `role="dialog"` reads
875
+ * to it as a declaration — the same defect that let a quoted `role="radio"` suppress
876
+ * no-raw-action-button (task 42, see `OpeningTag`). Here it would fire the other way, on
877
+ * a comment explaining why a dialog role is wrong.
841
878
  */
842
879
  const HANDROLLED_DIALOG_RE =
843
880
  /(?<!\[)\brole\s*=\s*(?:['"]dialog['"]|\{\s*['"]dialog['"]\s*\})|<DialogContent\b/g;
@@ -1059,9 +1096,12 @@ const noRawTitleAttr: Rule = {
1059
1096
  // to delete an a11y requirement. (unit 11)
1060
1097
  if (match[1] === "iframe") continue;
1061
1098
  const index = match.index ?? 0;
1062
- const tag = extractOpeningTag(source, index);
1063
- if (!TITLE_ATTR_RE.test(tag)) continue;
1064
- if (TITLE_ATTR_IGNORE_RE.test(tag)) continue;
1099
+ const tag = readOpeningTag(source, index);
1100
+ // Same split as no-raw-action-button, the other way up: reading comments with
1101
+ // an ATTRIBUTE regex here flags an element that has no `title` at all because
1102
+ // a comment in its attribute list mentions one ("not a title= hover hint").
1103
+ if (!TITLE_ATTR_RE.test(tag.code)) continue;
1104
+ if (TITLE_ATTR_IGNORE_RE.test(tag.text)) continue;
1065
1105
  const line = lineOf(source, index);
1066
1106
  const windowStart = Math.max(0, line - 3);
1067
1107
  if (TITLE_ATTR_IGNORE_RE.test(lines.slice(windowStart, line).join("\n"))) continue;
package/scripts/paths.ts CHANGED
@@ -38,7 +38,7 @@ import { dirname, join, resolve } from "node:path";
38
38
  import { fileURLToPath } from "node:url";
39
39
 
40
40
  /** The generation's tools this repo runs over its own tree, in the order they should fail. */
41
- const TOOLS = ["check-paths.ts", "check-doc-citations.ts"];
41
+ const TOOLS = ["check-paths.ts", "check-doc-citations.ts", "check-gate-graph.ts"];
42
42
 
43
43
  /** How far up either walk looks before giving up — a bound, so a misuse is `null` in ms. */
44
44
  const MAX_HOPS = 16;