@avi2dg/checks 0.45.1 → 0.46.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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.46.0
6
+
7
+ Released 2026-10-19.
8
+
9
+ ### Features
10
+
11
+ - **quality:** refuse CSS comments, non-ASCII selectors and stylesheets over 400 lines [#172](https://github.com/avi2d/checks/pull/172)
12
+
5
13
  ## 0.45.1
6
14
 
7
15
  Released 2026-10-19.
package/README.md CHANGED
@@ -172,6 +172,7 @@ The table groups the gates by vector, the part of a repository each one judges.
172
172
  | quality | [`checks-comment-gate`](docs/gates/checks-comment-gate.md) | the range | every repository |
173
173
  | quality | [`checks-effect-scope`](docs/gates/checks-effect-scope.md) | the working tree | a repository tracking `oxlint.config.ts` |
174
174
  | quality | [`checks-frontend-syntax`](docs/gates/checks-frontend-syntax.md) | the working tree | a repository tracking `frontend-syntax.json` |
175
+ | quality | [`checks-css-size`](docs/gates/checks-css-size.md) | the working tree | a repository tracking `*.css` |
175
176
  | quality | [`checks-no-plain-js`](docs/gates/checks-no-plain-js.md) | the working tree | a repository tracking `*.ts` or `*.tsx` |
176
177
  | testing | [`checks-test-layout`](docs/gates/checks-test-layout.md) | the working tree | a repository tracking `*.ts` or `*.tsx` |
177
178
  | testing | [`checks-quarantine-clock`](docs/gates/checks-quarantine-clock.md) | the range | every repository |
@@ -13,6 +13,12 @@ var SELECTOR_COMPLEXITY = {
13
13
  "selector-max-specificity": "0,3,2",
14
14
  "selector-max-compound-selectors": 3
15
15
  };
16
+ var COMMENT_REFUSAL = {
17
+ "comment-word-disallowed-list": ["/^[\\s\\S]*$/"]
18
+ };
19
+ var LANGUAGE_FREE_SELECTORS = {
20
+ "selector-disallowed-list": ["/[^\\x00-\\x7F]/"]
21
+ };
16
22
  var COLOUR_FUNCTIONS = ["rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color"];
17
23
  var DESIGN_TOKENS = {
18
24
  "color-no-hex": true,
@@ -61,6 +67,8 @@ function defineConfig({ baseline, tokens, rules, overrides = [], ...rest }) {
61
67
  const merged = {
62
68
  ...CONVENTION_CHANGES,
63
69
  ...SELECTOR_COMPLEXITY,
70
+ ...COMMENT_REFUSAL,
71
+ ...LANGUAGE_FREE_SELECTORS,
64
72
  [BASELINE_RULE]: [true, { available }],
65
73
  ...tokenRules.rules,
66
74
  ...rules
package/docs/design.md CHANGED
@@ -145,14 +145,18 @@ The violation it catches is a deleted workflow step, so it has to fail in the lo
145
145
  It parses workflows with `Bun.YAML`, which the `bun` shebang already provides, so it adds no dependency.
146
146
  It reads `on` as a string key, not as the YAML 1.1 boolean.
147
147
 
148
- ## Size and repetition only fail on growth
148
+ ## TypeScript size and repetition only fail on growth
149
149
 
150
- The size limits are oxlint's own rules at `error`, not a script of the kit's.
150
+ The TypeScript size limits are oxlint's own rules at `error`, not a script of the kit's.
151
151
  A script of the kit's own would restate how oxlint reads its config and compares sites, and each restatement adds cases oxlint itself does not have.
152
152
  A repository records its existing violations with `oxlint --suppress-all`, and `checks-suppressions-ratchet` refuses any count that rises.
153
153
  The cost is that a file or function already over its limit can grow without adding a site the count sees.
154
154
  Each repository works its counts down to zero.
155
155
 
156
+ `checks-css-size` holds each stylesheet to the same 400 lines as an absolute budget, with no count to ratchet.
157
+ oxlint does not read CSS, and no maintained stylelint rule or plugin limits lines per file, so the budget is a kit script.
158
+ A stylesheet already over 400 lines fails until it is split, whatever the suppression counts hold.
159
+
156
160
  `checks-repetition` runs jscpd at both ends of the range with the head's `.jscpd.json`, so the same `path` and `ignore` globs pick the files at both ends.
157
161
  It compares each file's count of repeated lines, and does not use jscpd's `--baseline-from-ref`.
158
162
  That flag reports a repeated block as new once its text changes, so a change that shortens an existing repeated block would fail.
@@ -101,12 +101,13 @@ A range that leaves `bun.lock` unchanged runs in under a second, and one that ch
101
101
 
102
102
  ## Running it on a schedule
103
103
 
104
- A range never fails on an advisory published after its package landed, so a scheduled `--all` run finds those:
104
+ A range never fails on an advisory published after its package landed, so a scheduled `--all` run finds those.
105
+ A repository schedules it at minute 17 of its tier's 30 minute night window, so repositories do not start together:
105
106
 
106
107
  ```yaml
107
108
  on:
108
109
  schedule:
109
- - cron: "41 4 * * *"
110
+ - cron: "17 22 * * *"
110
111
  workflow_dispatch:
111
112
  jobs:
112
113
  advisories:
@@ -0,0 +1,53 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-css-size
6
+
7
+ `checks-css-size` is the gate that holds each tracked stylesheet to 400 lines, the kit's own `max-lines` for code files.
8
+
9
+ ## What it checks
10
+
11
+ It fails on every `*.css` file in the working tree that holds more than 400 lines.
12
+ It counts every line, blank lines and comments included, the way `max-lines` counts code with blank lines and comments kept.
13
+ Each line names the file and its line count, and tells its writer to split the stylesheet into smaller files.
14
+
15
+ ## What it reads
16
+
17
+ It reads the working tree.
18
+ It scans the tracked and untracked files that `git ls-files --cached --others --exclude-standard` reports.
19
+ Ignore rules leave out untracked files only, so an untracked `node_modules/` is out of reach while a tracked stylesheet under an ignored directory is still measured.
20
+ A local run agrees with CI before `git add`.
21
+
22
+ ## Arguments
23
+
24
+ ```sh
25
+ checks-css-size [<directory>]
26
+ ```
27
+
28
+ It checks the directory it runs in, or the directory it is given.
29
+
30
+ ## Exit codes
31
+
32
+ | Code | When |
33
+ | --- | --- |
34
+ | 0 | every stylesheet holds 400 lines or fewer |
35
+ | 1 | a stylesheet holds more than 400 lines |
36
+ | 2 | git does not list the working tree, or a listed stylesheet cannot be read |
37
+
38
+ ## Sample output
39
+
40
+ ```
41
+ css-size: 1 violation(s)
42
+ src/styles.css: 1901 lines, over the 400-line budget; split the stylesheet into smaller files
43
+ ```
44
+
45
+ ## When it runs
46
+
47
+ `checks-lint` runs it when the repository tracks a `*.css` file.
48
+
49
+ ## Related topics
50
+
51
+ - [checks-frontend-syntax](checks-frontend-syntax.md)
52
+ - [checks-lint](checks-lint.md)
53
+ - [checks-repetition](checks-repetition.md)
@@ -101,12 +101,13 @@ Only a schedule the repository writes runs it.
101
101
 
102
102
  ## Running it on a schedule
103
103
 
104
- A repository runs it on a schedule and keeps the record of a failed run as an artifact:
104
+ A repository runs it on a schedule and keeps the record of a failed run as an artifact.
105
+ A repository schedules it at minute 27 of its tier's 30 minute night window, so repositories do not start together:
105
106
 
106
107
  ```yaml
107
108
  on:
108
109
  schedule:
109
- - cron: "17 5 * * *"
110
+ - cron: "27 22 * * *"
110
111
  workflow_dispatch:
111
112
  jobs:
112
113
  flake:
@@ -39,6 +39,8 @@ A control that needs selection off sets it on its own selector, such as `.drag-h
39
39
  | G3 selector complexity | `selector-max-specificity` at `0,3,2` and `selector-max-compound-selectors` at 3 | on |
40
40
  | G4 design tokens | `color-no-hex`, `color-named: "never"`, `function-disallowed-list` over `rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, `oklab`, `oklch` and `color`, and `font-size` only as one `var(--…)` with no fallback, or `inherit` | off until `tokens` names the token files, and off in those files |
41
41
  | G5 Baseline target | `plugin/use-baseline` from `stylelint-plugin-use-baseline`, which judges each property, value, function, at-rule, selector and unit against the Baseline data, and passes a property inside an `@supports` block. It passes `@property` at every target, since a browser without `@property` treats the declaration as an unregistered custom property | on, at the target `baseline` sets |
42
+ | G6 comments refused | `comment-word-disallowed-list` over `/^[\s\S]*$/`, which refuses every comment, including an empty `/* */` | on |
43
+ | G7 language-free selectors | `selector-disallowed-list` over `/[^\x00-\x7F]/`, which refuses any selector holding a non-ASCII character, in attribute values, class names and ids alike | on |
42
44
 
43
45
  The builder changes three of the shared config's rules:
44
46
 
@@ -47,7 +49,9 @@ The builder changes three of the shared config's rules:
47
49
  - `property-no-vendor-prefix` keeps reporting without a fix, and passes `-webkit-user-select`, since its fix deletes the prefix Safari still needs.
48
50
  - In an `.html` or `.astro` file, `declaration-block-single-line-max-declarations` is off, since a `style` attribute is one line.
49
51
 
52
+ The comment rule refuses every comment but a sourcemap `/*# */` one, which stylelint passes to keep the map.
50
53
  CSS repetition is not a family of this gate: a repository adds `"css"` to `format` in `.jscpd.json`, and [checks-repetition](checks-repetition.md) then fails when a stylesheet's repeated lines grow.
54
+ A stylesheet's length is not a family of this gate either, and [checks-css-size](checks-css-size.md) fails one that holds more than 400 lines.
51
55
 
52
56
  ### What no comment turns off
53
57
 
@@ -169,6 +173,7 @@ A repository without that file never runs it and needs neither `stylelint.config
169
173
  - [checks-browser](checks-browser.md)
170
174
  - [checks-lint](checks-lint.md)
171
175
  - [checks-comment-gate](checks-comment-gate.md)
176
+ - [checks-css-size](checks-css-size.md)
172
177
  - [checks-suppressions-ratchet](checks-suppressions-ratchet.md)
173
178
  - [checks-repetition](checks-repetition.md)
174
179
  - [Native settings](../configs/native-settings.md)
@@ -14,6 +14,7 @@ Gates requiring tracked TypeScript files begin running when the repository track
14
14
  `checks-advisories` begins running when the repository tracks `bun.lock`.
15
15
  `checks-frontend-syntax` begins running when the repository tracks `frontend-syntax.json` at its root.
16
16
  `checks-copy` begins running when the repository tracks `copy.json` at its root.
17
+ `checks-css-size` begins running when the repository tracks a `*.css` file.
17
18
  All other gates run for every repository.
18
19
 
19
20
  ## What it reads
@@ -48,7 +49,7 @@ With two arguments, the base and head override range discovery.
48
49
 
49
50
  ```
50
51
  checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
51
- checks-lint: 1 of 18 gate(s) failed: checks-comment-gate
52
+ checks-lint: 1 of 19 gate(s) failed: checks-comment-gate
52
53
  ```
53
54
 
54
55
  <!-- end generated lint-sample -->
@@ -80,13 +80,14 @@ Run the workflow by hand with `gh workflow run daily-release` to refresh the rel
80
80
  ## Running it in CI
81
81
 
82
82
  A repository takes the daily release as `.github/workflows/daily-release.yml`.
83
+ A repository schedules it at minute 7 of its tier's 30 minute night window, so repositories do not start together.
83
84
  The `pull-request` job runs on the schedule, and the `tag` job runs when a release commit lands on `main`, as [checks-release-tag](checks-release-tag.md) says:
84
85
 
85
86
  ```yaml
86
87
  name: daily-release
87
88
  on:
88
89
  schedule:
89
- - cron: "29 3 * * *"
90
+ - cron: "7 22 * * *"
90
91
  workflow_dispatch:
91
92
  push:
92
93
  branches: [main]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.45.1",
3
+ "version": "0.46.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -63,6 +63,7 @@
63
63
  "src/docs/prose-matchers.ts",
64
64
  "src/quality/comments.ts",
65
65
  "src/quality/comment-gate.ts",
66
+ "src/quality/css-size.ts",
66
67
  "src/quality/frontend-syntax.ts",
67
68
  "src/quality/browser/",
68
69
  "src/complexity/suppressions-ratchet.ts",
@@ -157,6 +158,7 @@
157
158
  "checks-subsumed-tests": "src/testing/subsumed-tests.ts",
158
159
  "checks-ci-wiring": "src/delivery/ci-wiring.ts",
159
160
  "checks-comment-gate": "src/quality/comment-gate.ts",
161
+ "checks-css-size": "src/quality/css-size.ts",
160
162
  "checks-suppressions-ratchet": "src/complexity/suppressions-ratchet.ts",
161
163
  "checks-repetition": "src/complexity/repetition.ts",
162
164
  "checks-unused": "src/complexity/unused.ts",
package/src/core/gates.ts CHANGED
@@ -40,6 +40,8 @@ const OXLINT_CONFIG: TrackedContent = { pathspecs: ["oxlint.config.ts"], content
40
40
 
41
41
  const FRONTEND_SYNTAX_DECLARATION: TrackedContent = { pathspecs: ["frontend-syntax.json"], content: "a frontend syntax declaration" };
42
42
 
43
+ const STYLESHEET: TrackedContent = { pathspecs: ["*.css"], content: "a stylesheet" };
44
+
43
45
  const COPY_DECLARATION: TrackedContent = { pathspecs: ["copy.json"], content: "a copy declaration" };
44
46
 
45
47
  export const KIT_GATES = [
@@ -49,6 +51,7 @@ export const KIT_GATES = [
49
51
  { bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
50
52
  { bin: "checks-effect-scope", vector: "quality", file: "effect-scope.ts", reads: "tree", args: ["--check"], appliesTo: OXLINT_CONFIG },
51
53
  { bin: "checks-frontend-syntax", vector: "quality", file: "frontend-syntax.ts", reads: "tree", appliesTo: FRONTEND_SYNTAX_DECLARATION },
54
+ { bin: "checks-css-size", vector: "quality", file: "css-size.ts", reads: "tree", appliesTo: STYLESHEET },
52
55
  { bin: "checks-no-plain-js", vector: "quality", file: "no-plain-js.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
53
56
  { bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
54
57
  { bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
package/src/core/main.ts CHANGED
@@ -26,3 +26,17 @@ export function runMain<E extends { readonly message: string }>(
26
26
  );
27
27
  BunRuntime.runMain(program, { teardown: (exit, onExit) => onExit(statusOf(exit)) });
28
28
  }
29
+
30
+ export function runTreeGate<Outcome extends { readonly violations: readonly unknown[] }>(
31
+ name: string,
32
+ check: (root: string) => Effect.Effect<Outcome, { readonly message: string }, BunServices.BunServices>,
33
+ format: (result: Outcome) => string,
34
+ ): void {
35
+ const gate = Effect.gen(function* () {
36
+ const root = process.argv[2] ?? process.cwd();
37
+ const result = yield* check(root);
38
+ yield* Console.log(format(result));
39
+ return result.violations.length === 0;
40
+ });
41
+ runMain(name, gate);
42
+ }
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bun
2
+ import { Effect, FileSystem, Path, Schema } from "effect";
3
+ import { workingTreeFiles } from "../core/git.ts";
4
+ import { runTreeGate } from "../core/main.ts";
5
+ import { SOURCE_LIMITS } from "./presets/oxlint.ts";
6
+
7
+ export type Violation = {
8
+ readonly file: string;
9
+ readonly line: number | undefined;
10
+ readonly message: string;
11
+ };
12
+
13
+ const NAME = "css-size";
14
+
15
+ const LIMIT = SOURCE_LIMITS["max-lines"];
16
+
17
+ const STYLESHEET = /\.css$/;
18
+
19
+ export function lineCount(source: string): number {
20
+ if (source === "") return 0;
21
+ const parts = source.split("\n");
22
+ return source.endsWith("\n") ? parts.length - 1 : parts.length;
23
+ }
24
+
25
+ export function violationsOf(entries: readonly { readonly file: string; readonly lines: number }[]): readonly Violation[] {
26
+ return entries
27
+ .filter(({ lines }) => lines > LIMIT)
28
+ .toSorted((a, b) => (a.file < b.file ? -1 : 1))
29
+ .map(({ file, lines }) => ({
30
+ file,
31
+ line: undefined,
32
+ message: `${lines} lines, over the ${LIMIT}-line budget; split the stylesheet into smaller files`,
33
+ }));
34
+ }
35
+
36
+ class CssSizeError extends Schema.TaggedError<CssSizeError>()("CssSizeError", {
37
+ message: Schema.String,
38
+ }) {}
39
+
40
+ export type Result = { readonly files: number; readonly violations: readonly Violation[] };
41
+
42
+ const listJudged = Effect.fn("listJudged")(function* (root: string) {
43
+ const listed = yield* workingTreeFiles(root).pipe(
44
+ Effect.mapError((cause) => new CssSizeError({ message: cause.message })),
45
+ );
46
+ return listed.filter((file) => STYLESHEET.test(file)).toSorted();
47
+ });
48
+
49
+ const measure = Effect.fn("measure")(function* (root: string, file: string) {
50
+ const fs = yield* FileSystem.FileSystem;
51
+ const path = yield* Path.Path;
52
+ const source = yield* fs.readFileString(path.join(root, file)).pipe(
53
+ Effect.mapError((cause) => new CssSizeError({ message: `cannot read ${file}: ${String(cause)}` })),
54
+ );
55
+ return { file, lines: lineCount(source) };
56
+ });
57
+
58
+ export const run = Effect.fn("run")(function* (root: string) {
59
+ const judged = yield* listJudged(root);
60
+ const entries = yield* Effect.forEach(judged, (file) => measure(root, file), { concurrency: "unbounded" });
61
+ return { files: judged.length, violations: violationsOf(entries) } satisfies Result;
62
+ });
63
+
64
+ export function report({ files, violations }: Result): string {
65
+ if (violations.length === 0) return `${NAME}: no stylesheet over ${LIMIT} lines among ${files} CSS file(s)`;
66
+ return [`${NAME}: ${violations.length} violation(s)`, ...violations.map(({ file, message }) => ` ${file}: ${message}`)].join("\n");
67
+ }
68
+
69
+ if (import.meta.main) runTreeGate(NAME, run, report);
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, Schema } from "effect";
2
+ import { Effect, Schema } from "effect";
3
3
  import { workingTreeFiles } from "../core/git.ts";
4
- import { runMain } from "../core/main.ts";
4
+ import { runTreeGate } from "../core/main.ts";
5
5
 
6
6
  export type Violation = {
7
7
  readonly file: string;
@@ -61,11 +61,4 @@ export function report({ files, violations }: Result): string {
61
61
  return [`${NAME}: ${violations.length} violation(s)`, ...violations.map(({ file, message }) => ` ${file}: ${message}`)].join("\n");
62
62
  }
63
63
 
64
- const noPlainJs = Effect.gen(function* () {
65
- const root = process.argv[2] ?? process.cwd();
66
- const result = yield* run(root);
67
- yield* Console.log(report(result));
68
- return result.violations.length === 0;
69
- });
70
-
71
- if (import.meta.main) runMain(NAME, noPlainJs);
64
+ if (import.meta.main) runTreeGate(NAME, run, report);
@@ -31,6 +31,16 @@ const SELECTOR_COMPLEXITY = {
31
31
  "selector-max-compound-selectors": 3,
32
32
  } as const satisfies Rules;
33
33
 
34
+ // [\s\S] without anchors passes an empty /**/, so the anchors refuse every comment text there is.
35
+ const COMMENT_REFUSAL = {
36
+ "comment-word-disallowed-list": ["/^[\\s\\S]*$/"],
37
+ } as const satisfies Rules;
38
+
39
+ // A non-ASCII class refuses every language at once, where naming scripts would chase each one.
40
+ const LANGUAGE_FREE_SELECTORS = {
41
+ "selector-disallowed-list": ["/[^\\x00-\\x7F]/"],
42
+ } as const satisfies Rules;
43
+
34
44
  const COLOUR_FUNCTIONS = ["rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color"] as const;
35
45
 
36
46
  const DESIGN_TOKENS = {
@@ -84,6 +94,8 @@ export function defineConfig({ baseline, tokens, rules, overrides = [], ...rest
84
94
  const merged: Rules = {
85
95
  ...CONVENTION_CHANGES,
86
96
  ...SELECTOR_COMPLEXITY,
97
+ ...COMMENT_REFUSAL,
98
+ ...LANGUAGE_FREE_SELECTORS,
87
99
  [BASELINE_RULE]: [true, { available }],
88
100
  ...tokenRules.rules,
89
101
  ...rules,