From ca7451b6fdfbf64c40b7df4ba47907d7a272061b Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Fri, 14 Aug 2026 14:57:48 +0200 Subject: [PATCH] Make an empty description an error, with a list that only shrinks An example with no description is still published: `llms.txt` carries it as a directory name and a tag, and a model choosing between 170 of those is choosing on a filename. Forty of them are in that state, which is legal because the schema types the field as a bare string. So the rule goes on at full strength -- empty is an error, and 120 characters is the bound -- and the examples that cannot pass it yet are named in `bin/description-exceptions.mjs`. That is the shape `bin/e2e-exceptions.mjs` already established here, down to its reasoning: a suite that is red for known reasons is a suite nobody reads, and "known" has to mean written down. The lists are the source of truth, and `test/description-exceptions.test.ts` reads them the way the lint cannot -- backwards. The lint only asks whether a failure was named, so on its own it would let an entry outlive its reason forever. The test fails on a carved-out example that has since been described, on a description that has since been emptied, and on a recorded length that has drifted. Touching one of these descriptions means updating the list in the same change. The five over the bound get their own list rather than a truncation. Each spends its overflow naming its stack in prose -- `bubbles` lists five postprocessing effects -- which is the habit the new `apis` field exists to absorb, so cutting at 120 would leave the same bad line, shorter. They are rewrites, and this has to land before any description is written. `schemas/pmndrs.schema.json` keeps the bare string on purpose. `minLength` and `maxLength` there would replay these 45 failures inside every editor that opens one of these files -- red for known reasons again, in a venue with no exception list to read. It tightens when the lists are empty, which is also what closes #192. Closes #195 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YQmbpRu6oCNrLiUQvhz5x6 --- bin/description-exceptions.mjs | 114 ++++++++++++++++++++++++++++ bin/validate-pmndrs-metadata.mjs | 20 +++++ test/description-exceptions.test.ts | 100 ++++++++++++++++++++++++ turbo.json | 1 + 4 files changed, 235 insertions(+) create mode 100644 bin/description-exceptions.mjs create mode 100644 test/description-exceptions.test.ts diff --git a/bin/description-exceptions.mjs b/bin/description-exceptions.mjs new file mode 100644 index 00000000..93daf44d --- /dev/null +++ b/bin/description-exceptions.mjs @@ -0,0 +1,114 @@ +// +// The two lists that carve out exceptions from "every example carries a +// description, and it fits on one line". +// +// UNDESCRIBED no description at all +// OVERLONG a description past the character bound +// +// --------------------------------------------------------------------------- +// +// `bin/build-llms.mjs` publishes all 170 examples to `llms.txt` and to +// `/examples/.md`, and the description is the only sentence most readers +// of that index will ever see about one of them. An example without one +// arrives there as a directory name and a tag -- a model choosing between 170 +// of those is choosing on a filename. That is why an empty description is an +// error and not a gap. +// +// `bin/e2e-exceptions.mjs` already establishes this shape in this repo, down +// to its reasoning: a suite that is red for known reasons is a suite nobody +// reads, and "known" has to mean written down. So the rule goes on at full +// strength today, and the examples that cannot pass it yet are named here, +// once, in a file whose whole subject is that they haven't been written. +// +// The lists are the source of truth. `bin/validate-pmndrs-metadata.mjs` +// errors on any description that is empty or over the bound unless the +// example is named here, and `test/description-exceptions.test.ts` fails when +// the lists and reality disagree -- in both directions, so an entry that has +// since been described is as loud as a description that has since been +// emptied. Neither list is a place to put a new failure: an entry is a promise +// to come back to it, the way out is `/describe-example `, which writes +// the line and deletes the entry in the same change, and the lists reaching +// zero is what closes #192. +// +// `schemas/pmndrs.schema.json` deliberately still types `description` as a +// bare string. `minLength` and `maxLength` there would replay these same 45 +// failures inside every editor that opens one of these files -- red for known +// reasons again, in a venue that has no exception list to read. The schema +// tightens when this file is empty. +// + +/** What a description has to fit in. Shared with the `describe-example` skill. */ +export const DESCRIPTION_MAX_LENGTH = 120; + +// +// Examples that ship `"description": ""`. Forty of the hundred and seventy, on +// 2026-08-14; the other hundred and thirty run to a median of 56 characters. +// +// Nothing groups them. They are not the old examples, or the small ones, or +// the ones nobody looks at -- `aquarium`, `caustics` and `portals` are here. +// The field was simply never required to hold anything, so for these forty it +// never got anything. +// +export const UNDESCRIBED = [ + "aquarium", + "bloom-hdr-workflow-gltf", + "cards", + "cards-with-border-radius", + "caustics", + "csg-bunny-usegroups", + "csg-house", + "csg-operations-rapier-physics", + "dbismut-furniture", + "diamond-ring", + "ecctrl-fisheye", + "enter-portals", + "environment-blur-and-transitions", + "envmap-ground-projection", + "faucets-select-highlight", + "gatsby-stars", + "glass-flower", + "ground-projected-envmaps-lamina", + "html-input-fields", + "inter-epoxy-resin", + "iridescent-decals", + "lamina-1x", + "lusion-connectors", + "magic-box", + "monitors", + "motionpathcontrols", + "nextjs-prism", + "pairing-threejs-to-ui", + "pass-through-portals", + "pmndrs-vercel", + "portal-shapes", + "portals", + "rapier-physics", + "react-ellipsecurve", + "shopping", + "ssgi-spheres-with-rapier-physics", + "stage-presets-gltfjsx", + "starwars", + "t-shirt-configurator", + "water-shader", +]; + +// +// Examples whose description is written, and too long. Five, with the length +// recorded so the entry can be checked rather than believed -- the test reads +// the file and fails on a number that has drifted. +// +// None of them is a truncation away from fitting. Each spends its overflow +// listing the stack in prose -- `bubbles` names five postprocessing effects, +// `pixelation` and `vignette` each spell out a package -- which is the +// identifiers-in-`description` habit that the `apis` field now exists to +// absorb. Cutting the sentence at 120 would leave the same bad line, shorter. +// So they are rewrites, they are `describe-example` work, and this change has +// to land before any description is written. +// +export const OVERLONG = { + bubbles: 147, + pixelation: 127, + "take-control": 133, + vignette: 122, + wireframes: 131, +}; diff --git a/bin/validate-pmndrs-metadata.mjs b/bin/validate-pmndrs-metadata.mjs index 80acbfe7..7cf9f24f 100644 --- a/bin/validate-pmndrs-metadata.mjs +++ b/bin/validate-pmndrs-metadata.mjs @@ -4,6 +4,11 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + DESCRIPTION_MAX_LENGTH, + OVERLONG, + UNDESCRIBED, +} from "./description-exceptions.mjs"; import { importedIdentifiers } from "./lib/imports.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -121,8 +126,23 @@ for (const exampleName of exampleNames) { if (typeof metadata.title !== "string" || metadata.title.length === 0) { addError(exampleName, '"title" must be a non-empty string'); } + // An empty description is published, as a directory name and nothing else, + // so it is an error rather than a gap. The examples that cannot pass this + // yet are named in `bin/description-exceptions.mjs`, with the reasoning; the + // lists only shrink, and leaving one is what `/describe-example` does. if (typeof metadata.description !== "string") { addError(exampleName, '"description" must be a string'); + } else if (metadata.description.trim().length === 0) { + if (!UNDESCRIBED.includes(exampleName)) { + addError(exampleName, '"description" is empty'); + } + } else if (metadata.description.length > DESCRIPTION_MAX_LENGTH) { + if (!(exampleName in OVERLONG)) { + addError( + exampleName, + `"description" is ${metadata.description.length} characters, over ${DESCRIPTION_MAX_LENGTH}`, + ); + } } for (const field of ["tags", "authors", "libraries"]) { diff --git a/test/description-exceptions.test.ts b/test/description-exceptions.test.ts new file mode 100644 index 00000000..0b77acf5 --- /dev/null +++ b/test/description-exceptions.test.ts @@ -0,0 +1,100 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + // @ts-expect-error -- plain ESM, no types, and none worth writing + DESCRIPTION_MAX_LENGTH, + // @ts-expect-error -- plain ESM, no types, and none worth writing + OVERLONG, + // @ts-expect-error -- plain ESM, no types, and none worth writing + UNDESCRIBED, +} from "../bin/description-exceptions.mjs"; + +/** + * `lint:metadata` only reads these lists one way: it asks whether an example + * that fails the rule was named, and stays quiet if it was. So the lint alone + * would let a described example keep its entry forever, and a list that + * outlives its reasons is the thing the whole pattern exists to prevent. + * + * These tests read them the other way. Every entry has to still be failing, + * every failure has to still be listed, and the recorded lengths have to still + * be the lengths -- so the only way to touch one of these descriptions is to + * update the list in the same change. + */ + +const root = path.resolve(import.meta.dirname, ".."); +const examplesDirectory = path.join(root, "examples"); + +const examples = readdirSync(examplesDirectory).filter((name) => + existsSync(path.join(examplesDirectory, name, "package.json")), +); + +function descriptionOf(name: string): string { + const metadata = JSON.parse( + readFileSync(path.join(examplesDirectory, name, "pmndrs.json"), "utf8"), + ); + return typeof metadata.description === "string" ? metadata.description : ""; +} + +const empty = examples.filter( + (name) => descriptionOf(name).trim().length === 0, +); + +const overlong = examples.filter((name) => { + const description = descriptionOf(name); + return ( + description.trim().length > 0 && description.length > DESCRIPTION_MAX_LENGTH + ); +}); + +describe("undescribed", () => { + it("names every example that has no description", () => { + const missing = empty.filter((name) => !UNDESCRIBED.includes(name)); + + expect(missing, "empty description, and not carved out").toEqual([]); + }); + + it("names nothing that has one", () => { + const stale = UNDESCRIBED.filter((name: string) => !empty.includes(name)); + + expect(stale, "carved out, yet described -- delete the line").toEqual([]); + }); + + it("names examples that exist", () => { + for (const name of UNDESCRIBED) { + expect(examples, `${name} is not an example`).toContain(name); + } + }); +}); + +describe("overlong", () => { + it("names every description past the bound", () => { + const missing = overlong.filter((name) => !(name in OVERLONG)); + + expect( + missing, + `over ${DESCRIPTION_MAX_LENGTH}, and not carved out`, + ).toEqual([]); + }); + + it("names nothing that fits", () => { + const stale = Object.keys(OVERLONG).filter( + (name) => !overlong.includes(name), + ); + + expect( + stale, + "carved out, yet within the bound -- delete the line", + ).toEqual([]); + }); + + it("records the length each one actually is", () => { + for (const [name, length] of Object.entries(OVERLONG)) { + expect(examples, `${name} is not an example`).toContain(name); + expect(descriptionOf(name).length, `${name} is no longer ${length}`).toBe( + length, + ); + } + }); +}); diff --git a/turbo.json b/turbo.json index 9ba7251f..40c26890 100644 --- a/turbo.json +++ b/turbo.json @@ -62,6 +62,7 @@ // which is the one thing the rule exists to catch. "inputs": [ "bin/validate-pmndrs-metadata.mjs", + "bin/description-exceptions.mjs", "bin/lib/imports.mjs", "schemas/pmndrs.schema.json", "examples/*/{package.json,pmndrs.json}",