Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions bin/description-exceptions.mjs
Original file line number Diff line number Diff line change
@@ -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/<name>.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 <name>`, 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,
};
20 changes: 20 additions & 0 deletions bin/validate-pmndrs-metadata.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)), "..");
Expand Down Expand Up @@ -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"]) {
Expand Down
100 changes: 100 additions & 0 deletions test/description-exceptions.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
});
});
1 change: 1 addition & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
Loading