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.
- package/dist/react/components/Autocomplete.d.ts.map +1 -1
- package/dist/react/components/Autocomplete.js +1 -1
- package/dist/react/components/Autocomplete.js.map +1 -1
- package/dist/react/components/ComboboxField.d.ts.map +1 -1
- package/dist/react/components/ComboboxField.js +1 -1
- package/dist/react/components/ComboboxField.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts +9 -2
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +12 -4
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/analytics.css +1 -1
- package/dist/styles-areas/data-table.css +1 -1
- package/dist/styles-areas/fields.css +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/wizard.css +1 -1
- package/package.json +50 -6
- package/scripts/demoServer.ts +211 -0
- package/scripts/gate.ts +101 -0
- package/scripts/guardrailsEnforce.spec.ts +69 -0
- package/scripts/guardrailsEnforce.ts +59 -19
- package/scripts/paths.ts +1 -1
- package/src/demoFixture.spec.ts +54 -10
- package/src/demoStaticServer.spec.ts +169 -0
- package/src/publishShape.spec.ts +79 -0
- package/src/react/components/Autocomplete.tsx +11 -1
- package/src/react/components/ComboboxField.spec.tsx +12 -0
- package/src/react/components/ComboboxField.tsx +11 -1
- package/src/react/media-gallery/MediaGallery.spec.tsx +23 -0
- package/src/react/media-gallery/MediaGallery.tsx +26 -9
- package/src/styles-areas/analytics.css +1 -1
- package/src/styles-areas/data-table.css +1 -1
- package/src/styles-areas/fields.css +1 -1
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/wizard.css +1 -1
- package/src/testFilesRunInParallel.spec.ts +3 -3
- package/src/typecheckCachesAreSeparate.spec.ts +2 -2
- package/src/verifyGraph.spec.ts +75 -108
- package/scripts/verify.ts +0 -296
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "4.
|
|
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 &&
|
|
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
|
|
51
|
-
"prepublishOnly": "bun run
|
|
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();
|
package/scripts/gate.ts
ADDED
|
@@ -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
|
-
/**
|
|
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.
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
449
|
-
// just blanked out of `scannable`) are still visible to the ignore
|
|
450
|
-
|
|
451
|
-
|
|
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 =
|
|
543
|
+
const tag = readOpeningTag(source, match.index);
|
|
514
544
|
const ignoreRe = /guardrails-ignore\s+no-raw-inputs\s*:/;
|
|
515
|
-
|
|
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 =
|
|
1063
|
-
|
|
1064
|
-
|
|
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;
|