Skip to content

feat: reduce the friction of adopting cssert (0.2.0) - #2

Merged
tokibito merged 2 commits into
mainfrom
feat/0.2.0-adoption-friction
Sep 13, 2026
Merged

tokibito merged 2 commits into
mainfrom
feat/0.2.0-adoption-friction

Conversation

@tokibito

@tokibito tokibito commented Sep 13, 2026 •

Copy link
Copy Markdown
Owner

Acts on a field report from a repository running @cssert/cli 0.1.0 in CI:
Django 6.1 + Tailwind v4 + daisyUI 5 + Vite, with the npm root in a frontend/
subdirectory. The report had no complaints about the detection itself — every
item is about the cost of wiring the tool in
.

Implements all of P1 (4), P2 (2) and P3 (2), plus documentation items A-F.

Breaking changes (4, each with an opt-out)

Change Restore the old behaviour
Relative paths in a config file resolve against the config file's directory resolveFrom: "cwd"
--format github emits one annotation per class --annotate-occurrences
A missing baseline file is a note on stderr, not exit 2 --require-baseline
baseline create freezes only the kinds that fail the check --kind all

1. Where globs resolve from

The new src/cli/roots.ts keeps two bases: paths written in a config file
resolve against that file's directory, paths passed as flags stay relative
to cwd. Reported paths are relative to whichever base found them, so the same
config produces identical output from any directory — the acceptance criterion
in the report. test/cli/roots.test.ts asserts byte-identical stdout from the
repository root, from frontend/, and from frontend/sub/.

Before this, a repository whose npm root is a subdirectory could not put cssert
in an npm script at all: cssert check --config ../cssert.config.mjs silently
matched nothing from there.

root (config, relative to the config file) and --root (CLI, relative to
cwd) override the base. Note that root in a config file rebases only that
file's own paths and leaves command-line globs on cwd
— "the file I pointed at
from here" is the obvious reading of a flag. --root is given at invocation
time and so rebases flags too.

2-4

  • GitHub annotations anchor at the first occurrence and carry the rest in the
    message (… (and 65 other places).), so N classes produce N annotations.
    The human format still lists every occurrence; SARIF still emits one result
    per occurrence, since code scanning deduplicates by fingerprint.
  • A missing baseline writes no baseline at ...; run "cssert baseline create" ... to stderr and continues. This fails louder, not quieter — nothing is
    suppressed. --no-baseline is new, and --baseline "" is now read as
    "disabled" rather than failing with EISDIR.
  • baseline create defaults to --kind failing (missing, plus
    dynamic-suspect only under --fail-on-dynamic). Entries keyed by a whole
    {% if %} expression rot on any edit to that expression and could not fail
    the build anyway. --kind all|missing|dynamic-suspect selects explicitly, and
    both create and prune take --dry-run.

Additions

  • Making unverified scope visible. The summary always reports how many
    documents still contain unresolved class expressions.
    --min-documents/--min-stylesheets turn a truncated input set into exit 1,
    surfaced in every format through the new Report.errors. This closes the hole
    where a failed CI artefact download passes as a green run over zero documents.
  • Finding provenance. JSON findings carry sources, the input globs they
    were seen under, so
    select(.sources == ["templates/**/*.html"]) extracts exactly the pages that
    were never rendered.
  • hooks/--hook for classes that are supposed to have no styles, and
    --version/--help naming the package.

Two deviations from the report

  • --expect-html <glob> is implemented as --min-documents <n>. The
    request wavered between a glob and a count, so this takes the "declare how
    many should have arrived" reading.
  • P2-5's "large, optional" proposal is not implemented — taking templates
    and rendered output as two distinct input kinds and reporting coverage
    between them. sources already makes the distinction mechanically available;
    worth seeing how that is used in practice first.

Documentation

  • README: "What a green run does and does not prove" (E), a baseline operations
    guide (D), a field report of what the tool found in practice (F), and an
    "Upgrading from 0.1" table for the four changed defaults.
  • docs/recipes/django.md: a pytest-based rendering recipe that reuses existing
    fixtures and asserts each page's status code (A), a note that rendering a page
    is not the same as covering it — both branches of every {% if %} need data
    (B), and the three-job GitHub Actions workflow (C).
  • docs/decisions.md: D28-D33 record the reasoning; D19 and D22 are marked
    superseded and amended.

Verification

  • 226 tests pass (new: test/cli/roots.test.ts, test/cli/coverage.test.ts);
    typecheck, biome and build are clean.
  • Coverage: 96.9% statements / 93.7% branches, no threshold regressions.
  • The reported reproductions were re-run against the built binary: invocation
    from the repository root and from frontend/, --baseline "", and a missing
    baseline file.

The release goes through changesets, so package.json is untouched — the
version bump to 0.2.0 happens in the Version Packages PR as docs/releasing.md
describes.

Acts on a field report from a Django + Tailwind v4 + daisyUI repository that
ran @cssert/cli 0.1.0 in CI. The detection itself held up; every item below is
about the cost of getting it wired in.

Four defaults change, each with an opt-out:

- Relative paths in a config file resolve against the config file's directory
  rather than cwd, the way eslint/vitest/tsc resolve theirs. A repository whose
  npm root is a subdirectory could not put cssert in an npm script, because
  `cssert check --config ../cssert.config.mjs` matched nothing from there.
  Globs passed as flags stay cwd-relative. `root`/`--root` override the base and
  `resolveFrom: "cwd"` restores the old behaviour.
- `--format github` emits one annotation per class, anchored at the first
  occurrence with the rest counted in the message. GitHub caps the annotations
  it shows per check run, so one class used in 85 places hid every other
  finding, worst on the first run. `--annotate-occurrences` restores the old
  behaviour; human and SARIF are unchanged.
- A baseline file that does not exist is a note on stderr, not exit 2. Writing
  the config before freezing the findings is the normal order of work.
  `--require-baseline` makes it fatal, `--no-baseline` (or an empty path, which
  used to fail with EISDIR) ignores an existing one.
- `baseline create` freezes only the kinds that fail the check. Entries keyed by
  a whole `{% if %}` expression rot on any edit to that expression and could not
  fail the build anyway. `--kind all` restores the old behaviour.

Added, so that a green run says what it did not verify:

- `--min-documents`/`--min-stylesheets` fail the run when fewer inputs arrived
  than expected, reported through the new `Report.errors`. A CI artefact that
  failed to download must not pass as a green check.
- The summary counts documents that still contain unresolved class expressions.
- JSON findings carry `sources`, the input globs they were seen under, so a
  finding listed only under a template glob is a page nobody rendered.
- `hooks`/`--hook` for classes that are supposed to have no styles, `baseline
  create|prune --dry-run`, and `--version` naming the package.
README gains "What a green run does and does not prove" (existence, only over
the files it was given) so the adopting side can quote it instead of writing
it themselves, a baseline operations guide covering what to freeze and what
not to, a field report of what the tool found in practice, and an upgrade
table for the four changed defaults.

The Django recipe gains a pytest-based rendering recipe that reuses the
existing test fixtures and asserts each page's status code, a note that
rendering a page is not the same as covering it (both branches of every
`{% if %}` need data), and the three-job GitHub Actions workflow that the CSS
and HTML coming from different toolchains actually requires.

D28-D33 record the reasoning; D19 and D22 are marked superseded and amended.
@tokibito
tokibito force-pushed the feat/0.2.0-adoption-friction branch from 6ee71b1 to eb65f75 Compare September 13, 2026 14:52
@tokibito
tokibito merged commit 7d7558d into main Sep 13, 2026
4 checks passed
@tokibito
tokibito deleted the feat/0.2.0-adoption-friction branch September 13, 2026 14:54
@github-actions github-actions Bot mentioned this pull request Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant