cursedbelt 4.1.1 → 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/CanvasVideoPlayer.d.ts +3 -2
- package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.js +4 -3
- package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +16 -2
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +68 -43
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/PictureEditor.d.ts +17 -2
- package/dist/react/media/PictureEditor.d.ts.map +1 -1
- package/dist/react/media/PictureEditor.js +33 -7
- package/dist/react/media/PictureEditor.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +17 -2
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +21 -3
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts +16 -2
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +21 -3
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/VideoPlayer.d.ts +37 -9
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +130 -39
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts +24 -3
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +109 -8
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/pictureEditor.css +2 -0
- package/dist/react/media/sourceFault.d.ts +146 -0
- package/dist/react/media/sourceFault.d.ts.map +1 -0
- package/dist/react/media/sourceFault.js +139 -0
- package/dist/react/media/sourceFault.js.map +1 -0
- package/dist/react/media/videoChapterEditor.css +19 -0
- package/dist/react/media/videoCutEditor.css +22 -0
- package/dist/react/media-gallery/MediaGallery.d.ts +21 -3
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +178 -17
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
- package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.js +27 -3
- package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
- package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +23 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +1 -0
- 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/media.css +1 -1
- package/dist/styles-areas/wizard.css +1 -1
- package/package.json +51 -6
- package/scripts/demoServer.ts +211 -0
- package/scripts/gate.ts +101 -0
- package/scripts/guardrailsEnforce.spec.ts +313 -1
- package/scripts/guardrailsEnforce.ts +283 -26
- 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/CanvasVideoPlayer.tsx +11 -6
- package/src/react/media/FrameGrabber.spec.tsx +57 -1
- package/src/react/media/FrameGrabber.tsx +138 -84
- package/src/react/media/PictureEditor.spec.tsx +90 -0
- package/src/react/media/PictureEditor.tsx +78 -23
- package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
- package/src/react/media/VideoChapterEditor.tsx +64 -18
- package/src/react/media/VideoCutEditor.spec.tsx +44 -0
- package/src/react/media/VideoCutEditor.tsx +63 -18
- package/src/react/media/VideoPlayer.spec.tsx +101 -0
- package/src/react/media/VideoPlayer.tsx +221 -73
- package/src/react/media/hlsSource.answer.spec.ts +255 -0
- package/src/react/media/hlsSource.ts +136 -8
- package/src/react/media/pictureEditor.css +2 -0
- package/src/react/media/sourceFault.spec.tsx +224 -0
- package/src/react/media/sourceFault.tsx +220 -0
- package/src/react/media/videoChapterEditor.css +19 -0
- package/src/react/media/videoCutEditor.css +22 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +311 -0
- package/src/react/media-gallery/MediaGallery.tsx +382 -54
- package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
- package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
- package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
- package/src/react/media-gallery/types.ts +23 -0
- 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/media.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
|
@@ -8,13 +8,31 @@
|
|
|
8
8
|
// invoked from each package's `lint`/`done` script, exiting non-zero on violation).
|
|
9
9
|
//
|
|
10
10
|
// Usage:
|
|
11
|
-
// bun run ../cursedbelt/scripts/guardrailsEnforce.ts <rule>[,<rule>...]
|
|
12
|
-
// [--
|
|
11
|
+
// bun run ../cursedbelt/scripts/guardrailsEnforce.ts <rule>[,<rule>...]
|
|
12
|
+
// [--scope <name> | --baseline <path>]
|
|
13
|
+
// [--reimplements <Name,Name,…> | --reimplements-from-belt]
|
|
14
|
+
// [--exclude <path>[,<path>…]] [root...]
|
|
13
15
|
//
|
|
14
16
|
// `--scope <name>` selects the ratchet baseline row (see "Ratchet baseline" below) for
|
|
15
17
|
// the calling package — `cursedbelt` and `cursedshell` each pass their own scope name
|
|
16
18
|
// from their `lint` script. Omitting `--scope` defaults every rule's baseline to 0.
|
|
17
19
|
//
|
|
20
|
+
// A CONSUMING APP uses the other three instead, and needs no wrapper script of its own:
|
|
21
|
+
//
|
|
22
|
+
// "guardrails": "bun node_modules/cursedbelt/scripts/guardrailsEnforce.ts <rules>
|
|
23
|
+
// --baseline scripts/guardrails-baseline.json --reimplements-from-belt src"
|
|
24
|
+
//
|
|
25
|
+
// --baseline <path> a flat `{ "<rule>": <count> }` ratchet in the CALLER's
|
|
26
|
+
// repo, TWO-SIDED — exceeding a row fails, and so does
|
|
27
|
+
// falling below one, which prints the number to paste.
|
|
28
|
+
// The rows and the scanned rules must agree exactly, both
|
|
29
|
+
// directions. See "a CONSUMER's ratchet" below.
|
|
30
|
+
// --reimplements-from-belt derive `no-local-reimplementation`'s name list from THIS
|
|
31
|
+
// package's own react index files, so no app hand-maintains
|
|
32
|
+
// 384 names. Fails closed on an empty list.
|
|
33
|
+
// --exclude <path>[,<path>…] drop build output that lives under a source root. Fails
|
|
34
|
+
// if it matches nothing.
|
|
35
|
+
//
|
|
18
36
|
// Currently implemented rules:
|
|
19
37
|
// no-raw-action-button — STANDARDS.md §4a: "a plugin or generated app must not render
|
|
20
38
|
// a raw <button> for an action (a raw <button> is acceptable only for a purely
|
|
@@ -148,7 +166,7 @@
|
|
|
148
166
|
// ignored (its violations still fire) and a warning is printed.
|
|
149
167
|
|
|
150
168
|
import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
151
|
-
import { basename, extname, join, relative } from "node:path";
|
|
169
|
+
import { basename, extname, join, relative, resolve } from "node:path";
|
|
152
170
|
|
|
153
171
|
const IGNORE_DIR_NAMES = new Set(["node_modules", "dist", "dist-deploy", ".git", "build", "coverage"]);
|
|
154
172
|
const SOURCE_EXTENSIONS = new Set([".tsx", ".jsx", ".css"]);
|
|
@@ -240,7 +258,24 @@ function skipQuoted(source: string, start: number): number {
|
|
|
240
258
|
return i;
|
|
241
259
|
}
|
|
242
260
|
|
|
243
|
-
/**
|
|
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
|
|
244
279
|
* `{...}` JS expressions in attribute values that may themselves contain `>`
|
|
245
280
|
* (`onClick={() => …}`, which works because that `>` sits inside braces).
|
|
246
281
|
*
|
|
@@ -248,30 +283,40 @@ function skipQuoted(source: string, start: number): number {
|
|
|
248
283
|
* so it may contain an angle bracket ("a radio-like list rather than a `<select>`").
|
|
249
284
|
* Those are skipped the same way `blankComments` skips them, because a `>` written in prose
|
|
250
285
|
* used to end the tag here and make every attribute below it invisible — the
|
|
251
|
-
* `guardrails-ignore` opt-out included.
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
|
|
255
|
-
|
|
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, " ");
|
|
256
291
|
let i = startIndex;
|
|
257
292
|
let braceDepth = 0;
|
|
293
|
+
let code = "";
|
|
258
294
|
while (i < source.length) {
|
|
259
295
|
const two = source.slice(i, i + 2);
|
|
260
296
|
if (two === "//") {
|
|
261
297
|
const end = source.indexOf("\n", i);
|
|
262
|
-
|
|
298
|
+
const stop = end === -1 ? source.length : end;
|
|
299
|
+
code += blank(source.slice(i, stop));
|
|
300
|
+
i = stop;
|
|
263
301
|
continue;
|
|
264
302
|
}
|
|
265
303
|
if (two === "/*") {
|
|
266
304
|
const end = source.indexOf("*/", i + 2);
|
|
267
|
-
|
|
305
|
+
const stop = end === -1 ? source.length : end + 2;
|
|
306
|
+
code += blank(source.slice(i, stop));
|
|
307
|
+
i = stop;
|
|
268
308
|
continue;
|
|
269
309
|
}
|
|
270
310
|
const ch = source[i];
|
|
271
311
|
if (ch === '"' || ch === "'" || ch === "`") {
|
|
272
|
-
|
|
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;
|
|
273
317
|
continue;
|
|
274
318
|
}
|
|
319
|
+
code += ch;
|
|
275
320
|
if (ch === "{") braceDepth++;
|
|
276
321
|
else if (ch === "}") braceDepth--;
|
|
277
322
|
else if (ch === ">" && braceDepth <= 0) {
|
|
@@ -280,7 +325,7 @@ function extractOpeningTag(source: string, startIndex: number): string {
|
|
|
280
325
|
}
|
|
281
326
|
i++;
|
|
282
327
|
}
|
|
283
|
-
return source.slice(startIndex, i);
|
|
328
|
+
return { text: source.slice(startIndex, i), code };
|
|
284
329
|
}
|
|
285
330
|
|
|
286
331
|
function lineOf(source: string, index: number): number {
|
|
@@ -427,10 +472,13 @@ const noRawActionButton: Rule = {
|
|
|
427
472
|
const scannable = blankNonMarkupText(source);
|
|
428
473
|
RAW_BUTTON_RE.lastIndex = 0;
|
|
429
474
|
for (const match of scannable.matchAll(RAW_BUTTON_RE)) {
|
|
430
|
-
// Re-
|
|
431
|
-
// just blanked out of `scannable`) are still visible to the ignore
|
|
432
|
-
|
|
433
|
-
|
|
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;
|
|
434
482
|
const line = lineOf(source, match.index);
|
|
435
483
|
violations.push({
|
|
436
484
|
file: filePath,
|
|
@@ -492,9 +540,10 @@ const noRawInputs: Rule = {
|
|
|
492
540
|
const scannable = blankNonMarkupText(source);
|
|
493
541
|
RAW_INPUT_RE.lastIndex = 0;
|
|
494
542
|
for (const match of scannable.matchAll(RAW_INPUT_RE)) {
|
|
495
|
-
const tag =
|
|
543
|
+
const tag = readOpeningTag(source, match.index);
|
|
496
544
|
const ignoreRe = /guardrails-ignore\s+no-raw-inputs\s*:/;
|
|
497
|
-
|
|
545
|
+
// `.text`: this rule's only opt-out IS a comment, so it reads the comments.
|
|
546
|
+
if (ignoreRe.test(tag.text)) continue;
|
|
498
547
|
const line = lineOf(source, match.index);
|
|
499
548
|
violations.push({
|
|
500
549
|
file: filePath,
|
|
@@ -820,6 +869,12 @@ const ISDIRTY_PROP_RE = /\bisDirty\s*=/;
|
|
|
820
869
|
* primitive. The `(?<!\[)` matters — `body().querySelector('[role="dialog"]')`
|
|
821
870
|
* contains the same characters and is a test READING a dialog, not declaring one;
|
|
822
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.
|
|
823
878
|
*/
|
|
824
879
|
const HANDROLLED_DIALOG_RE =
|
|
825
880
|
/(?<!\[)\brole\s*=\s*(?:['"]dialog['"]|\{\s*['"]dialog['"]\s*\})|<DialogContent\b/g;
|
|
@@ -1041,9 +1096,12 @@ const noRawTitleAttr: Rule = {
|
|
|
1041
1096
|
// to delete an a11y requirement. (unit 11)
|
|
1042
1097
|
if (match[1] === "iframe") continue;
|
|
1043
1098
|
const index = match.index ?? 0;
|
|
1044
|
-
const tag =
|
|
1045
|
-
|
|
1046
|
-
|
|
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;
|
|
1047
1105
|
const line = lineOf(source, index);
|
|
1048
1106
|
const windowStart = Math.max(0, line - 3);
|
|
1049
1107
|
if (TITLE_ATTR_IGNORE_RE.test(lines.slice(windowStart, line).join("\n"))) continue;
|
|
@@ -1678,18 +1736,122 @@ function loadBaseline(): Baseline {
|
|
|
1678
1736
|
}
|
|
1679
1737
|
}
|
|
1680
1738
|
|
|
1739
|
+
// ── `--baseline <path>`: a CONSUMER's ratchet, in the consumer's own repo ───────
|
|
1740
|
+
// The packaged baseline above cannot be a consuming app's: it lives inside
|
|
1741
|
+
// `node_modules/cursedbelt`, so nobody can check their debt ceiling in beside their
|
|
1742
|
+
// code. Nine apps each carried an identical ~140-line wrapper to work around that
|
|
1743
|
+
// (`apps/*/scripts/guardrails.ts`, deleted 2026-09-18 by tasks/…/202-*.md); this flag
|
|
1744
|
+
// is that wrapper, folded into the one place it belongs.
|
|
1745
|
+
//
|
|
1746
|
+
// The file is FLAT — `{ "<rule>": <count> }`, no scope level, because a repo's
|
|
1747
|
+
// baseline is about one repo. A `"$comment"` key is reserved for the why (desk's row
|
|
1748
|
+
// list carries fifty lines of it) and is the only non-numeric value tolerated:
|
|
1749
|
+
// anything else non-numeric is a typo in a row, and a typo read as `?? 0` would demand
|
|
1750
|
+
// a whole inherited pile in one commit, so it is refused by name instead.
|
|
1751
|
+
//
|
|
1752
|
+
// 🔴 It is TWO-SIDED, and the packaged `--scope` baseline is not. Deliberate, and the
|
|
1753
|
+
// asymmetry is the point: `--scope` names a row the CALLER cannot edit (it ships inside
|
|
1754
|
+
// the package), so failing a consumer for a number only a cursedbelt release can lower
|
|
1755
|
+
// would be a gate nobody can turn green. `--baseline` names a file in the caller's own
|
|
1756
|
+
// repo, which they lower in the same commit — so falling below a row fails, printing
|
|
1757
|
+
// the number to paste. A one-sided ratchet accumulates slack: fix forty colours, leave
|
|
1758
|
+
// the row at 353, and forty may quietly come back. Same idiom as the generation's
|
|
1759
|
+
// `check-paths` baseline — inherited debt only falls.
|
|
1760
|
+
type FlatBaseline = Record<string, number>;
|
|
1761
|
+
|
|
1762
|
+
const BASELINE_COMMENT_KEY = "$comment";
|
|
1763
|
+
|
|
1764
|
+
/** Parse a flat `{rule: count}` baseline, refusing anything that is not a count. */
|
|
1765
|
+
function parseFlatBaseline(json: string, label: string): FlatBaseline {
|
|
1766
|
+
let raw: unknown;
|
|
1767
|
+
try {
|
|
1768
|
+
raw = JSON.parse(json);
|
|
1769
|
+
} catch (error) {
|
|
1770
|
+
throw new Error(`${label}: invalid JSON — ${(error as Error).message}`);
|
|
1771
|
+
}
|
|
1772
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
1773
|
+
throw new Error(`${label}: expected an object of {"<rule>": <count>}`);
|
|
1774
|
+
}
|
|
1775
|
+
const rows: FlatBaseline = {};
|
|
1776
|
+
for (const [rule, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
1777
|
+
if (rule === BASELINE_COMMENT_KEY) continue;
|
|
1778
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
1779
|
+
throw new Error(`${label}: "${rule}" is ${JSON.stringify(value)}, not a violation count`);
|
|
1780
|
+
}
|
|
1781
|
+
rows[rule] = value;
|
|
1782
|
+
}
|
|
1783
|
+
return rows;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* Both directions of "the rows and the rules must agree", which is the hole this
|
|
1788
|
+
* closes for the wrappers that used to do it by hand. A rule with no row is scored 0
|
|
1789
|
+
* and demands its whole inherited pile in one commit; a row with no rule is coverage
|
|
1790
|
+
* that is not there — the same defect as a `check-paths` baseline entry matching
|
|
1791
|
+
* nothing. Returns the complaints, empty when the two lists agree.
|
|
1792
|
+
*/
|
|
1793
|
+
function baselineDisagreements(rows: FlatBaseline, ruleNames: readonly string[]): string[] {
|
|
1794
|
+
const complaints: string[] = [];
|
|
1795
|
+
const missing = ruleNames.filter((name) => !(name in rows));
|
|
1796
|
+
const orphans = Object.keys(rows).filter((name) => !ruleNames.includes(name));
|
|
1797
|
+
if (missing.length > 0) complaints.push(`no row for rule(s) being scanned: ${missing.join(", ")}`);
|
|
1798
|
+
if (orphans.length > 0) complaints.push(`row(s) for rules nobody scans: ${orphans.join(", ")}`);
|
|
1799
|
+
return complaints;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// ── `--reimplements-from-belt`: the name list, off this package's own exports ───
|
|
1803
|
+
// `no-local-reimplementation` matches app-declared names against the ones cursedbelt
|
|
1804
|
+
// exports (384 of them as this lands). A hand-kept copy of a dependency's export list
|
|
1805
|
+
// is a second source of truth that drifts the moment the belt publishes, so the list
|
|
1806
|
+
// is read off the react index files of the INSTALLED package — this file's own
|
|
1807
|
+
// package, whatever version resolved. `--reimplements <Name,…>` stays for a caller
|
|
1808
|
+
// with a list of its own (the CURSEDBELT_INVENTORY.md path in the rule's header).
|
|
1809
|
+
const BELT_REACT_ROOT = join(import.meta.dir, "..", "src", "react");
|
|
1810
|
+
|
|
1811
|
+
export function beltExportedComponentNames(reactRoot: string = BELT_REACT_ROOT): string[] {
|
|
1812
|
+
const names = new Set<string>();
|
|
1813
|
+
const indexFiles: string[] = [];
|
|
1814
|
+
// `withFileTypes` rather than a `statSync` per entry — the react tree is ~1,300
|
|
1815
|
+
// entries, and one syscall each is 4s of a gate that is meant to be under a minute.
|
|
1816
|
+
const collect = (dir: string) => {
|
|
1817
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1818
|
+
const full = join(dir, entry.name);
|
|
1819
|
+
if (entry.isDirectory()) collect(full);
|
|
1820
|
+
else if (entry.name === "index.ts" || entry.name === "index.tsx") indexFiles.push(full);
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
collect(reactRoot);
|
|
1824
|
+
for (const file of indexFiles) {
|
|
1825
|
+
const source = readFileSync(file, "utf8");
|
|
1826
|
+
for (const block of source.matchAll(/export\s*\{([^}]*)\}/gs)) {
|
|
1827
|
+
for (const part of block[1]?.split(",") ?? []) {
|
|
1828
|
+
const trimmed = part.trim();
|
|
1829
|
+
if (!trimmed || trimmed.startsWith("type ")) continue;
|
|
1830
|
+
const name = (trimmed.split(/\s+as\s+/).pop() ?? "").trim();
|
|
1831
|
+
if (/^[A-Z][A-Za-z0-9]*$/.test(name)) names.add(name);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
return [...names];
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1681
1838
|
function main() {
|
|
1682
1839
|
const [ruleArg, ...rest] = process.argv.slice(2);
|
|
1683
1840
|
const ruleNames = ruleArg ? ruleArg.split(",") : [];
|
|
1684
1841
|
const rules = ruleNames.map((n) => RULES[n]).filter((r): r is Rule => Boolean(r));
|
|
1685
1842
|
if (ruleNames.length === 0 || rules.length !== ruleNames.length) {
|
|
1686
1843
|
console.error(
|
|
1687
|
-
`Usage: bun run guardrailsEnforce.ts <rule>[,<rule>...] [--scope <name>
|
|
1844
|
+
`Usage: bun run guardrailsEnforce.ts <rule>[,<rule>...] [--scope <name> | --baseline <path>]\n` +
|
|
1845
|
+
` [--reimplements <Name,…> | --reimplements-from-belt] [--exclude <path>[,<path>…]] [root...]\n` +
|
|
1846
|
+
`Known rules: ${Object.keys(RULES).join(", ")}`,
|
|
1688
1847
|
);
|
|
1689
1848
|
process.exit(2);
|
|
1690
1849
|
}
|
|
1691
1850
|
|
|
1692
1851
|
let scope: string | undefined;
|
|
1852
|
+
let baselinePath: string | undefined;
|
|
1853
|
+
let fromBelt = false;
|
|
1854
|
+
const excludes: string[] = [];
|
|
1693
1855
|
const roots: string[] = [];
|
|
1694
1856
|
for (let i = 0; i < rest.length; i++) {
|
|
1695
1857
|
if (rest[i] === "--scope") {
|
|
@@ -1697,6 +1859,12 @@ function main() {
|
|
|
1697
1859
|
i++;
|
|
1698
1860
|
continue;
|
|
1699
1861
|
}
|
|
1862
|
+
// A flat `{rule: count}` ratchet in the CALLER's repo — see "a CONSUMER's ratchet".
|
|
1863
|
+
if (rest[i] === "--baseline") {
|
|
1864
|
+
baselinePath = rest[i + 1];
|
|
1865
|
+
i++;
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1700
1868
|
// The inventoried component names `no-local-reimplementation` looks for. Passed
|
|
1701
1869
|
// in rather than hardcoded so the list lives beside the inventory it comes from
|
|
1702
1870
|
// (fleet/standards/CURSEDBELT_INVENTORY.md) — see that rule's header.
|
|
@@ -1705,10 +1873,73 @@ function main() {
|
|
|
1705
1873
|
i++;
|
|
1706
1874
|
continue;
|
|
1707
1875
|
}
|
|
1876
|
+
if (rest[i] === "--reimplements-from-belt") {
|
|
1877
|
+
fromBelt = true;
|
|
1878
|
+
continue;
|
|
1879
|
+
}
|
|
1880
|
+
// Build output that lives under a source root (desk's `src/generated/assets.ts` is
|
|
1881
|
+
// the compiled browser app, inlined as a module and rewritten by every build). Named
|
|
1882
|
+
// here rather than by listing every sibling root, because "name the siblings" fails
|
|
1883
|
+
// OPEN — a directory added later is silently unscanned.
|
|
1884
|
+
if (rest[i] === "--exclude") {
|
|
1885
|
+
for (const part of (rest[i + 1] ?? "").split(",")) {
|
|
1886
|
+
const trimmed = part.trim();
|
|
1887
|
+
if (trimmed) excludes.push(resolve(trimmed));
|
|
1888
|
+
}
|
|
1889
|
+
i++;
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1708
1892
|
roots.push(rest[i]);
|
|
1709
1893
|
}
|
|
1894
|
+
|
|
1895
|
+
if (scope && baselinePath) {
|
|
1896
|
+
console.error("--scope and --baseline are two different ratchets; pass one.");
|
|
1897
|
+
process.exit(2);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
let flatBaseline: FlatBaseline | undefined;
|
|
1901
|
+
if (baselinePath) {
|
|
1902
|
+
if (!existsSync(baselinePath)) {
|
|
1903
|
+
console.error(`--baseline ${baselinePath}: no such file (relative to ${process.cwd()})`);
|
|
1904
|
+
process.exit(2);
|
|
1905
|
+
}
|
|
1906
|
+
try {
|
|
1907
|
+
flatBaseline = parseFlatBaseline(readFileSync(baselinePath, "utf8"), baselinePath);
|
|
1908
|
+
} catch (error) {
|
|
1909
|
+
console.error((error as Error).message);
|
|
1910
|
+
process.exit(2);
|
|
1911
|
+
}
|
|
1912
|
+
const complaints = baselineDisagreements(flatBaseline, ruleNames);
|
|
1913
|
+
if (complaints.length > 0) {
|
|
1914
|
+
console.error(`${baselinePath} and the rules being scanned disagree:`);
|
|
1915
|
+
for (const complaint of complaints) console.error(` ${complaint}`);
|
|
1916
|
+
process.exit(2);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
if (fromBelt) {
|
|
1921
|
+
// 🔴 Fails CLOSED. An empty list makes `no-local-reimplementation` inert — it would
|
|
1922
|
+
// print "0 violations" for a check that never ran, and a ratchet row of 0 would then
|
|
1923
|
+
// look earned. If the react tree has moved, the run stops instead.
|
|
1924
|
+
let names: string[] = [];
|
|
1925
|
+
try {
|
|
1926
|
+
names = beltExportedComponentNames();
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
console.error(`--reimplements-from-belt: cannot read ${BELT_REACT_ROOT} — ${(error as Error).message}`);
|
|
1929
|
+
process.exit(2);
|
|
1930
|
+
}
|
|
1931
|
+
if (names.length === 0) {
|
|
1932
|
+
console.error(`--reimplements-from-belt: ${BELT_REACT_ROOT} exports no component names — refusing to scan against an empty list.`);
|
|
1933
|
+
process.exit(2);
|
|
1934
|
+
}
|
|
1935
|
+
setReimplementedNames(names);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1710
1938
|
const baseline = loadBaseline();
|
|
1711
|
-
const scopeBaseline = (scope
|
|
1939
|
+
const scopeBaseline: Record<string, number> = flatBaseline ?? (scope ? (baseline[scope] ?? {}) : {});
|
|
1940
|
+
// `--baseline` names a file the caller owns and can lower; `--scope` does not. That is
|
|
1941
|
+
// the whole reason one is two-sided and the other suggests.
|
|
1942
|
+
const twoSided = flatBaseline !== undefined;
|
|
1712
1943
|
|
|
1713
1944
|
const searchRoots = roots.length > 0 ? roots : ["."];
|
|
1714
1945
|
const files: string[] = [];
|
|
@@ -1721,6 +1952,17 @@ function main() {
|
|
|
1721
1952
|
// every directory, e.g. a plugin package with no `apps/` dir)
|
|
1722
1953
|
}
|
|
1723
1954
|
}
|
|
1955
|
+
if (excludes.length > 0) {
|
|
1956
|
+
const kept = files.filter((file) => !excludes.some((dir) => resolve(file) === dir || resolve(file).startsWith(`${dir}/`)));
|
|
1957
|
+
if (kept.length === files.length) {
|
|
1958
|
+
// An exclusion matching nothing is either a typo or a directory that has moved,
|
|
1959
|
+
// and both read as "that build output is handled" while it is being scanned.
|
|
1960
|
+
console.error(`--exclude matched no scanned file: ${excludes.join(", ")}`);
|
|
1961
|
+
process.exit(2);
|
|
1962
|
+
}
|
|
1963
|
+
files.length = 0;
|
|
1964
|
+
files.push(...kept);
|
|
1965
|
+
}
|
|
1724
1966
|
|
|
1725
1967
|
const sourcesByFile = new Map<string, string>();
|
|
1726
1968
|
for (const file of files) sourcesByFile.set(file, readFileSync(file, "utf8"));
|
|
@@ -1751,13 +1993,28 @@ function main() {
|
|
|
1751
1993
|
}
|
|
1752
1994
|
|
|
1753
1995
|
const allowedByBaseline = scopeBaseline[rule.name] ?? 0;
|
|
1754
|
-
if (violations.length
|
|
1996
|
+
if (violations.length === allowedByBaseline) {
|
|
1755
1997
|
console.log(
|
|
1756
1998
|
`✓ ${rule.name}: ${violations.length} violation(s) across ${files.length} files (baseline allows ${allowedByBaseline})`,
|
|
1757
1999
|
);
|
|
1758
|
-
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
2002
|
+
if (violations.length < allowedByBaseline) {
|
|
2003
|
+
// The ratchet's other side. Under `--scope` this is a suggestion printed at the
|
|
2004
|
+
// end; under `--baseline` it is a failure, because the caller owns that file and
|
|
2005
|
+
// leaving slack in it is how a row stops meaning anything.
|
|
2006
|
+
if (!twoSided) {
|
|
2007
|
+
console.log(
|
|
2008
|
+
`✓ ${rule.name}: ${violations.length} violation(s) across ${files.length} files (baseline allows ${allowedByBaseline})`,
|
|
2009
|
+
);
|
|
1759
2010
|
lowerableBaselines[rule.name] = violations.length;
|
|
2011
|
+
continue;
|
|
1760
2012
|
}
|
|
2013
|
+
anyExceededBaseline = true;
|
|
2014
|
+
console.error(
|
|
2015
|
+
`✗ ${rule.name}: ${violations.length} violation(s), below the baseline of ${allowedByBaseline} — lower "${rule.name}" ` +
|
|
2016
|
+
`to ${violations.length} in ${baselinePath} (the ratchet only falls)`,
|
|
2017
|
+
);
|
|
1761
2018
|
continue;
|
|
1762
2019
|
}
|
|
1763
2020
|
|
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;
|
package/src/demoFixture.spec.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { resolve } from 'node:path';
|
|
|
4
4
|
import {
|
|
5
5
|
DEMO_COMMAND,
|
|
6
6
|
DEMO_DEV_SCRIPT,
|
|
7
|
+
DEMO_DIST_DIR,
|
|
8
|
+
DEMO_HOST,
|
|
7
9
|
DEMO_PACKAGE_DIR,
|
|
8
10
|
DEMO_PORT,
|
|
9
11
|
DEMO_ROOT,
|
|
@@ -12,7 +14,8 @@ import {
|
|
|
12
14
|
} from '../demo/devServer';
|
|
13
15
|
import { DEMO_TSCONFIG, derivePaths, readDemoTsconfig, renderPathsBlock } from '../demo/generatePaths';
|
|
14
16
|
import pkg from '../package.json';
|
|
15
|
-
|
|
17
|
+
// The declared graph, read as data — there is no scheduler in this repo to import from.
|
|
18
|
+
const STAGES: readonly { script: string }[] = (pkg as { gate?: { stages?: { script: string }[] } }).gate?.stages ?? [];
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* 🔴 The e2e suite must never be able to sit green while it drives nothing.
|
|
@@ -56,19 +59,38 @@ describe('the e2e fixture exists', () => {
|
|
|
56
59
|
}
|
|
57
60
|
expect(pkg.scripts.demo, 'the demo script must start vite on the demo config').toInclude('demo/vite.config.ts');
|
|
58
61
|
// Playwright drives the BUILT bundle, not the dev server — see demo/devServer.ts.
|
|
59
|
-
expect(pkg.scripts['demo:serve'], 'demo:serve must build before it
|
|
60
|
-
|
|
62
|
+
expect(pkg.scripts['demo:serve'], 'demo:serve must build before it serves').toInclude('demo:build');
|
|
63
|
+
// 🔴 And it serves it from OUR server, never `vite preview` — which under Bun 1.4.2
|
|
64
|
+
// exits 1 with ERR_STREAM_WRITE_AFTER_END when a Playwright worker hangs up
|
|
65
|
+
// mid-response, reddening this gate about one run in four (task 388, 2026-09-18).
|
|
66
|
+
// Going back to it is a one-word edit, so it is a one-line assertion.
|
|
67
|
+
expect(pkg.scripts['demo:serve'], 'demo:serve must run scripts/demoServer.ts').toInclude('scripts/demoServer.ts');
|
|
68
|
+
expect(pkg.scripts['demo:serve'], '`vite preview` dies on a client hang-up under Bun').not.toInclude(
|
|
69
|
+
'vite preview',
|
|
70
|
+
);
|
|
61
71
|
});
|
|
62
72
|
|
|
63
|
-
it('
|
|
73
|
+
it('every server serves the port the specs are pointed at', () => {
|
|
64
74
|
// vite silently walks to the next free port unless `strictPort` is set, which is
|
|
65
75
|
// the same outage wearing a costume: a server that starts perfectly well and a
|
|
66
|
-
// suite that times out against the port nobody is listening on.
|
|
76
|
+
// suite that times out against the port nobody is listening on. `Bun.serve` throws
|
|
77
|
+
// EADDRINUSE rather than walking, and `demoStaticServer.spec.ts` proves it does.
|
|
67
78
|
const viteConfig = readFileSync(resolve(DEMO_ROOT, 'vite.config.ts'), 'utf8');
|
|
68
|
-
expect(DEMO_URL).toBe(`http
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
expect(DEMO_URL).toBe(`http://${DEMO_HOST}:${DEMO_PORT}`);
|
|
80
|
+
// 🔴 A literal address, never `localhost`. Measured 2026-09-18: two demo servers
|
|
81
|
+
// bound 4318 at the same time — one on `[::1]`, one on `127.0.0.1` — because
|
|
82
|
+
// `localhost` resolves to both and each process took whichever its resolver offered
|
|
83
|
+
// first. Neither bind collided, and which one a client reached was its own
|
|
84
|
+
// resolution order. A name here is a gate that can silently test a stale bundle.
|
|
85
|
+
expect(DEMO_HOST, 'the fixture must not be reached through a name that resolves twice').toBe('127.0.0.1');
|
|
86
|
+
expect(viteConfig, 'the dev server must bind the same one address').toInclude('host: DEMO_HOST');
|
|
87
|
+
// Once, for `server` — the dev server a human runs. There is no `preview` block any
|
|
88
|
+
// more, and a second match here would mean one grew back unread.
|
|
89
|
+
expect(viteConfig.match(/port: DEMO_PORT/g) ?? [], 'the dev server must pin the port').toHaveLength(1);
|
|
90
|
+
expect(viteConfig.match(/strictPort: true/g) ?? [], 'and must set strictPort').toHaveLength(1);
|
|
91
|
+
expect(viteConfig, 'the preview block is dead config now — e2e is served by scripts/demoServer.ts').not.toInclude(
|
|
92
|
+
'preview:',
|
|
93
|
+
);
|
|
72
94
|
});
|
|
73
95
|
|
|
74
96
|
it('every page the specs navigate to is a real demo entry point', () => {
|
|
@@ -98,13 +120,35 @@ describe('the e2e fixture exists', () => {
|
|
|
98
120
|
expect(unlisted, `demo/vite.config.ts's rollup input omits:\n ${unlisted.join('\n ')}`).toEqual([]);
|
|
99
121
|
});
|
|
100
122
|
|
|
123
|
+
it('🔴 never reuses a server somebody else started', () => {
|
|
124
|
+
// The gate's meaning must not depend on what the previous agent in this checkout
|
|
125
|
+
// left running. `reuseExistingServer: !process.env.CI` meant exactly that: `CI` is
|
|
126
|
+
// unset on this Mac, so a stale listener on 4318 was silently adopted and the demo
|
|
127
|
+
// build was skipped. Two agents measured the same command at 34.8s and 45.0s on
|
|
128
|
+
// 2026-09-18 for that reason — the fast one tested a bundle nobody had rebuilt.
|
|
129
|
+
const config = readFileSync(resolve(e2eDir, 'playwright.config.ts'), 'utf8');
|
|
130
|
+
expect(config, 'the webServer must always be ours').toInclude('reuseExistingServer: false');
|
|
131
|
+
expect(config, 'reuse must not be conditional on an env var this machine never sets').not.toMatch(
|
|
132
|
+
/reuseExistingServer:\s*!?\s*process\.env/,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('serves exactly the directory the demo build writes', () => {
|
|
137
|
+
// `scripts/demoServer.ts` serves DEMO_DIST_DIR; vite writes to `outDir` resolved
|
|
138
|
+
// against its `root`. A custom `outDir` would leave the server pointed at an empty
|
|
139
|
+
// directory and every spec 404ing against a 200-answering process.
|
|
140
|
+
const viteConfig = readFileSync(resolve(DEMO_ROOT, 'vite.config.ts'), 'utf8');
|
|
141
|
+
expect(DEMO_DIST_DIR).toBe(resolve(DEMO_ROOT, 'dist'));
|
|
142
|
+
expect(viteConfig, 'demoServer.ts assumes vite’s default outDir').not.toInclude('outDir');
|
|
143
|
+
});
|
|
144
|
+
|
|
101
145
|
it('verify actually runs the suite', () => {
|
|
102
146
|
// Everything above proves the fixture resolves. Only this proves anybody looks:
|
|
103
147
|
// without it, `e2e` drops back out of the gate and this whole file becomes a
|
|
104
148
|
// comment about a suite nothing runs.
|
|
105
149
|
//
|
|
106
150
|
// 🔴 `verify` stopped being an `&&` chain on 2026-09-15 and became a dependency
|
|
107
|
-
// graph (`
|
|
151
|
+
// graph (the `gate` block in `package.json`), so "the script mentions e2e" is no longer where the
|
|
108
152
|
// answer lives. Asking the graph is strictly stronger than asking the string was:
|
|
109
153
|
// `verifyGraph.spec.ts` additionally pins that every OTHER stage of the old chain
|
|
110
154
|
// is still in it, which the `toInclude` never checked for any of them.
|