From 1d9593a0867cd4a98167a0b38a2b1b457476171e Mon Sep 17 00:00:00 2001 From: tintinthong Date: Sun, 5 Jul 2026 15:59:12 +0800 Subject: [PATCH 1/3] Add listing conformance harness (scripts/conformance) Standalone Node 20 stdlib harness with 14 static gates (structure, JSON:API shapes, realm-bricking relationship classes, date-shape contract, formats, CQ fitted, thunk links, theme tokens, icon CDN, reuse inventory, AI-slop signals) and 5 dynamic gate wrappers (boxel parse/lint, module-load probe, typed-search counts, prerender smoke). Waiver file support, machine-readable report (report-schema.json), broken-listing fixture + selftest. Full-catalog static sweep: 26 listings in ~1s, 0 harness errors. Co-Authored-By: Claude Fable 5 --- .boxelignore | 3 + .gitignore | 5 +- scripts/conformance/README.md | 63 +++++++ .../broken-listing/CardListing/broken.json | 33 ++++ .../fixtures/broken-listing/Event/gala.json | 44 +++++ .../fixtures/broken-listing/event.gts | 48 ++++++ .../fixtures/broken-listing/venue.gts | 23 +++ scripts/conformance/gates/d01-boxel-parse.mjs | 35 ++++ scripts/conformance/gates/d02-boxel-lint.mjs | 51 ++++++ scripts/conformance/gates/d03-module-load.mjs | 43 +++++ .../conformance/gates/d04-typed-search.mjs | 46 +++++ scripts/conformance/gates/d05-prerender.mjs | 60 +++++++ scripts/conformance/gates/s01-structure.mjs | 142 +++++++++++++++ .../conformance/gates/s02-json-validity.mjs | 53 ++++++ .../conformance/gates/s03-instance-shape.mjs | 58 +++++++ .../conformance/gates/s04-no-inline-media.mjs | 37 ++++ .../conformance/gates/s05-relative-refs.mjs | 105 ++++++++++++ scripts/conformance/gates/s06-date-shape.mjs | 58 +++++++ .../conformance/gates/s07-formats-present.mjs | 42 +++++ scripts/conformance/gates/s08-fitted-cq.mjs | 45 +++++ scripts/conformance/gates/s09-thunk-links.mjs | 31 ++++ scripts/conformance/gates/s10-theme-vars.mjs | 46 +++++ scripts/conformance/gates/s11-cardinfo.mjs | 26 +++ scripts/conformance/gates/s12-icon-cdn.mjs | 55 ++++++ .../conformance/gates/s13-reuse-inventory.mjs | 161 ++++++++++++++++++ .../conformance/gates/s14-slop-signals.mjs | 100 +++++++++++ scripts/conformance/lib/exec.mjs | 21 +++ scripts/conformance/lib/gts-scan.mjs | 154 +++++++++++++++++ scripts/conformance/lib/json-walk.mjs | 89 ++++++++++ scripts/conformance/lib/report.mjs | 109 ++++++++++++ scripts/conformance/report-schema.json | 60 +++++++ .../rules/base-field-manifest.json | 22 +++ .../conformance/rules/boxel-ui-manifest.json | 17 ++ scripts/conformance/rules/reuse-rules.mjs | 80 +++++++++ scripts/conformance/run.mjs | 147 ++++++++++++++++ scripts/conformance/selftest.mjs | 90 ++++++++++ 36 files changed, 2201 insertions(+), 1 deletion(-) create mode 100644 scripts/conformance/README.md create mode 100644 scripts/conformance/fixtures/broken-listing/CardListing/broken.json create mode 100644 scripts/conformance/fixtures/broken-listing/Event/gala.json create mode 100644 scripts/conformance/fixtures/broken-listing/event.gts create mode 100644 scripts/conformance/fixtures/broken-listing/venue.gts create mode 100644 scripts/conformance/gates/d01-boxel-parse.mjs create mode 100644 scripts/conformance/gates/d02-boxel-lint.mjs create mode 100644 scripts/conformance/gates/d03-module-load.mjs create mode 100644 scripts/conformance/gates/d04-typed-search.mjs create mode 100644 scripts/conformance/gates/d05-prerender.mjs create mode 100644 scripts/conformance/gates/s01-structure.mjs create mode 100644 scripts/conformance/gates/s02-json-validity.mjs create mode 100644 scripts/conformance/gates/s03-instance-shape.mjs create mode 100644 scripts/conformance/gates/s04-no-inline-media.mjs create mode 100644 scripts/conformance/gates/s05-relative-refs.mjs create mode 100644 scripts/conformance/gates/s06-date-shape.mjs create mode 100644 scripts/conformance/gates/s07-formats-present.mjs create mode 100644 scripts/conformance/gates/s08-fitted-cq.mjs create mode 100644 scripts/conformance/gates/s09-thunk-links.mjs create mode 100644 scripts/conformance/gates/s10-theme-vars.mjs create mode 100644 scripts/conformance/gates/s11-cardinfo.mjs create mode 100644 scripts/conformance/gates/s12-icon-cdn.mjs create mode 100644 scripts/conformance/gates/s13-reuse-inventory.mjs create mode 100644 scripts/conformance/gates/s14-slop-signals.mjs create mode 100644 scripts/conformance/lib/exec.mjs create mode 100644 scripts/conformance/lib/gts-scan.mjs create mode 100644 scripts/conformance/lib/json-walk.mjs create mode 100644 scripts/conformance/lib/report.mjs create mode 100644 scripts/conformance/report-schema.json create mode 100644 scripts/conformance/rules/base-field-manifest.json create mode 100644 scripts/conformance/rules/boxel-ui-manifest.json create mode 100644 scripts/conformance/rules/reuse-rules.mjs create mode 100644 scripts/conformance/run.mjs create mode 100644 scripts/conformance/selftest.mjs diff --git a/.boxelignore b/.boxelignore index 9ac9e6f3..72089b38 100644 --- a/.boxelignore +++ b/.boxelignore @@ -6,3 +6,6 @@ LICENSE tests .github scripts +.conformance-waivers.yaml +.conformance-report.json +.boxel-history diff --git a/.gitignore b/.gitignore index b13ebc35..92366983 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -.claude/ \ No newline at end of file +.claude/ +.conformance-report.json +**/.conformance-report.json +.boxel-history/ diff --git a/scripts/conformance/README.md b/scripts/conformance/README.md new file mode 100644 index 00000000..4ba9041e --- /dev/null +++ b/scripts/conformance/README.md @@ -0,0 +1,63 @@ +# Listing conformance harness + +Machine-checkable quality gates for catalog listing packages. Run it on any +top-level listing directory (one containing a `Listing/*.json`): + +``` +node scripts/conformance/run.mjs \ + [--phase static|full] # default static; full adds boxel-cli / realm gates + [--realm ] # dev realm for the dynamic gates (D02-D05) + [--json ] # report path (default /.conformance-report.json, gitignored) + [--waivers ] # default /.conformance-waivers.yaml + [--offline] # skip the icon-CDN network gate (S12) +``` + +Exit codes: `0` pass (warnings allowed) · `1` errors found · `2` harness fault. + +## Phases + +- **Static (S01–S14)** — pure Node 20 stdlib, no install, <15s. Runs in CI + (`.github/workflows/conformance.yaml`) on changed listing dirs in PRs and in + a nightly sweep over the whole catalog. The only network call is S12 (icon + CDN HEAD), skippable with `--offline`. +- **Dynamic (D01–D05)** — local/pre-PR only: `npx boxel parse`, realm `/_lint`, + module-load probe (Gate A), typed-search counts (Gate B), prerender smoke. + Needs `@cardstack/boxel-cli` ≥0.4 and (for D02–D05) `--realm`. + +## Severities + +- **error** — realm-bricking / render-crashing / structurally broken. Blocks. +- **warn** — quality rules (theme tokens, reuse, slop signals). Warnings never + fail the run, but the card factory's conform step requires each one to be + **fixed or waived with a reason** — adjudicated, never silently ignored. +- **info** — advisory (e.g. a similar listing already exists). + +## Waivers + +`/.conformance-waivers.yaml` travels with the PR (it is excluded +from realm sync via `.boxelignore`): + +```yaml +waivers: + - rule: reuse/hand-rolled-button + file: piano.gts + reason: "Piano-key buttons need custom pressed-state animation; Button's chrome fights it" +``` + +`file` is optional (omit to waive the rule package-wide). Reviewers see the +justification in the diff; CI applies the same waivers the local run used. + +## Report + +`--json` emits a machine-readable report (schema: `report-schema.json`). Each +finding carries `rule`, `severity`, `file`, `loc`, `message`, `suggestion`, and +`fixRef` (a pointer into the card-factory skill tree), so an agent can consume +the report as a fix-list. + +## Extending + +One module per gate in `gates/`, uniform interface `{ id, title, phase, run(ctx) }` +returning findings. Shared scanning helpers live in `lib/` (`gts-scan.mjs` is a +regex/brace scanner, deliberately not a parser — anything subtler belongs in +glint/eslint via `ci-lint.yaml`). Reuse/slop rule tables live in `rules/`. +Self-test: `node scripts/conformance/selftest.mjs`. diff --git a/scripts/conformance/fixtures/broken-listing/CardListing/broken.json b/scripts/conformance/fixtures/broken-listing/CardListing/broken.json new file mode 100644 index 00000000..20507214 --- /dev/null +++ b/scripts/conformance/fixtures/broken-listing/CardListing/broken.json @@ -0,0 +1,33 @@ +{ + "data": { + "type": "card", + "meta": { + "adoptsFrom": { + "name": "CardListing", + "module": "https://app.boxel.ai/catalog/catalog-app/listing/listing" + } + }, + "attributes": { + "name": "Broken Fixture", + "summary": "Intentionally broken listing package used by the conformance selftest.", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "examples.0": { + "links": { + "self": "../Event/gala" + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": null + } + } + } + } +} diff --git a/scripts/conformance/fixtures/broken-listing/Event/gala.json b/scripts/conformance/fixtures/broken-listing/Event/gala.json new file mode 100644 index 00000000..3eb3805d --- /dev/null +++ b/scripts/conformance/fixtures/broken-listing/Event/gala.json @@ -0,0 +1,44 @@ +{ + "data": { + "type": "card", + "meta": { + "adoptsFrom": { + "module": "../event", + "name": "Event" + } + }, + "attributes": { + "title": "Item 1", + "startDate": "2026-07-04T19:00:00.000Z", + "posterData": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "speakers": { + "links": { + "self": ["../Speaker/ada", "../Speaker/grace"] + } + }, + "heroImage": { + "links": { + "self": "https://images.unsplash.com/photo-1519681393784-d120267933ba.jpg" + } + }, + "venue": { + "links": { + "self": "https://app.boxel.ai/justin-thong/cf-broken/Venue/main" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} diff --git a/scripts/conformance/fixtures/broken-listing/event.gts b/scripts/conformance/fixtures/broken-listing/event.gts new file mode 100644 index 00000000..81d9f1d2 --- /dev/null +++ b/scripts/conformance/fixtures/broken-listing/event.gts @@ -0,0 +1,48 @@ +// FIXTURE — intentionally violates conformance gates for selftest.mjs. +// Seeded bugs: bare kit-internal linksTo (S09), slop gradient (S14), +// no @container in fitted (S08 warn). +import { + CardDef, + field, + contains, + linksTo, + Component, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import DateField from 'https://cardstack.com/base/date'; +import { Venue } from './venue'; + +export class Event extends CardDef { + static displayName = 'Event'; + @field title = contains(StringField); + @field startDate = contains(DateField); + @field venue = linksTo(Venue); + + static isolated = class extends Component { + + }; + + static embedded = class extends Component { + + }; + + static fitted = class extends Component { + + }; +} diff --git a/scripts/conformance/fixtures/broken-listing/venue.gts b/scripts/conformance/fixtures/broken-listing/venue.gts new file mode 100644 index 00000000..0f22b39d --- /dev/null +++ b/scripts/conformance/fixtures/broken-listing/venue.gts @@ -0,0 +1,23 @@ +// FIXTURE — minimal linked def for the bare-linksTo seed in event.gts. +import { CardDef, field, contains, Component } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; + +export class Venue extends CardDef { + static displayName = 'Venue'; + @field name = contains(StringField); + + static isolated = class extends Component { + + }; + static embedded = class extends Component { + + }; + static fitted = class extends Component { + + }; +} diff --git a/scripts/conformance/gates/d01-boxel-parse.mjs b/scripts/conformance/gates/d01-boxel-parse.mjs new file mode 100644 index 00000000..6295c1be --- /dev/null +++ b/scripts/conformance/gates/d01-boxel-parse.mjs @@ -0,0 +1,35 @@ +// D01 — glint typecheck + card-document validation via `npx boxel parse`. +// Dynamic phase only (needs the boxel-cli npm package; CI's fast job skips it). +import { runCli } from '../lib/exec.mjs'; + +export default { + id: 'd01-boxel-parse', + title: 'boxel parse (glint + document validation)', + phase: 'dynamic', + async run(ctx) { + const { code, stdout, stderr } = await runCli( + ['boxel', '-q', 'parse', '--workspace', ctx.rootDir, '--json'], + { timeoutMs: 180_000 } + ); + if (code === 0) return []; + let diagnostics = []; + try { + const parsed = JSON.parse(stdout); + diagnostics = parsed.diagnostics ?? parsed.messages ?? parsed.errors ?? []; + } catch { + return [{ + rule: 'parse/failed', + severity: 'error', + file: '.', + message: `boxel parse exited ${code}: ${(stderr || stdout).slice(0, 400)}`, + }]; + } + return diagnostics.map((d) => ({ + rule: 'parse/diagnostic', + severity: 'error', + file: d.file ?? d.path ?? '.', + loc: d.line ? `line ${d.line}` : undefined, + message: d.message ?? JSON.stringify(d).slice(0, 300), + })); + }, +}; diff --git a/scripts/conformance/gates/d02-boxel-lint.mjs b/scripts/conformance/gates/d02-boxel-lint.mjs new file mode 100644 index 00000000..baafe6f1 --- /dev/null +++ b/scripts/conformance/gates/d02-boxel-lint.mjs @@ -0,0 +1,51 @@ +// D02 — realm-server lint (eslint + prettier with @cardstack/boxel rules) via +// `npx boxel lint`. Requires --realm: the card modules must already be pushed. +import { runCli } from '../lib/exec.mjs'; + +export default { + id: 'd02-boxel-lint', + title: 'Realm lint (/_lint endpoint)', + phase: 'dynamic', + async run(ctx) { + if (!ctx.options.realm) { + return [{ + rule: 'lint/no-realm', + severity: 'warn', + file: '.', + message: 'D02 skipped: no --realm provided (realm lint runs against pushed modules)', + }]; + } + const { code, stdout, stderr } = await runCli( + ['boxel', '-q', 'lint', '--realm', ctx.options.realm, '--json'], + { timeoutMs: 180_000 } + ); + let files = []; + try { + const parsed = JSON.parse(stdout); + files = Array.isArray(parsed) ? parsed : parsed.files ?? parsed.results ?? []; + } catch { + if (code !== 0) { + return [{ + rule: 'lint/failed', + severity: 'error', + file: '.', + message: `boxel lint exited ${code}: ${(stderr || stdout).slice(0, 400)}`, + }]; + } + return []; + } + const findings = []; + for (const f of files) { + for (const m of f.messages ?? []) { + findings.push({ + rule: `lint/${m.ruleId ?? 'message'}`, + severity: m.severity === 2 || m.severity === 'error' ? 'error' : 'warn', + file: f.filePath ?? f.path ?? '.', + loc: m.line ? `line ${m.line}` : undefined, + message: m.message, + }); + } + } + return findings; + }, +}; diff --git a/scripts/conformance/gates/d03-module-load.mjs b/scripts/conformance/gates/d03-module-load.mjs new file mode 100644 index 00000000..69236695 --- /dev/null +++ b/scripts/conformance/gates/d03-module-load.mjs @@ -0,0 +1,43 @@ +// D03 — Gate A: the realm server actually evaluates each definition module. +// `cardOrThunk was undefined` here means a bad import or unresolved cycle that +// lint and typecheck cannot see. +import { runCli } from '../lib/exec.mjs'; + +export default { + id: 'd03-module-load', + title: 'Module-load probe (Gate A)', + phase: 'dynamic', + async run(ctx) { + if (!ctx.options.realm) { + return [{ rule: 'gate-a/no-realm', severity: 'warn', file: '.', message: 'D03 skipped: no --realm provided' }]; + } + const realm = ctx.options.realm.endsWith('/') ? ctx.options.realm : `${ctx.options.realm}/`; + const findings = []; + for (const def of ctx.kit.defs) { + if (!def.exported || def.kind !== 'card') continue; + const moduleUrl = `${realm}${def.file.replace(/\.(gts|gjs)$/, '')}`; + const input = JSON.stringify({ codeRef: { module: moduleUrl, name: def.name } }); + const { code, stdout, stderr } = await runCli( + [ + 'boxel', '-q', 'run-command', + '@cardstack/boxel-host/commands/get-card-type-schema/default', + '--realm', realm, '--input', input, '--json', + ], + { timeoutMs: 60_000 } + ); + const out = stdout + stderr; + if (code !== 0 || !/"status"\s*:\s*"ready"|schema/i.test(out)) { + findings.push({ + rule: 'gate-a/module-load-failed', + severity: 'error', + file: def.file, + loc: `class ${def.name}`, + message: `Module-load probe failed for ${def.name}: ${out.slice(0, 300)}`, + suggestion: '"cardOrThunk was undefined" → bad import (named vs default) or missing thunk on a cyclic relationship', + fixRef: 'boxel/references/common-imports.md', + }); + } + } + return findings; + }, +}; diff --git a/scripts/conformance/gates/d04-typed-search.mjs b/scripts/conformance/gates/d04-typed-search.mjs new file mode 100644 index 00000000..f7769c4a --- /dev/null +++ b/scripts/conformance/gates/d04-typed-search.mjs @@ -0,0 +1,46 @@ +// D04 — Gate B: typed-search count in the realm equals the on-disk instance +// count per CardDef. `boxel search` is the truth source — lint passing while +// the index is empty has happened repeatedly. +import { runCli } from '../lib/exec.mjs'; + +export default { + id: 'd04-typed-search', + title: 'Typed-search counts (Gate B)', + phase: 'dynamic', + async run(ctx) { + if (!ctx.options.realm) { + return [{ rule: 'gate-b/no-realm', severity: 'warn', file: '.', message: 'D04 skipped: no --realm provided' }]; + } + const realm = ctx.options.realm.endsWith('/') ? ctx.options.realm : `${ctx.options.realm}/`; + const findings = []; + for (const def of ctx.kit.defs) { + if (!def.exported || def.kind !== 'card') continue; + // instances adopting this def: /*.json convention + const expected = ctx.files.filter((f) => new RegExp(`(^|/)${def.name}/[^/]+\\.json$`).test(f)).length; + if (expected === 0) continue; + const moduleUrl = `${realm}${def.file.replace(/\.(gts|gjs)$/, '')}`; + const query = JSON.stringify({ filter: { type: { module: moduleUrl, name: def.name } } }); + const { stdout } = await runCli( + ['boxel', '-q', 'search', '--realm', realm, '--query', query, '--json'], + { timeoutMs: 60_000 } + ); + let count = null; + try { + const parsed = JSON.parse(stdout); + count = Array.isArray(parsed) ? parsed.length : (parsed.results?.length ?? parsed.data?.length ?? null); + } catch { /* fall through */ } + if (count === null || count < expected) { + findings.push({ + rule: 'gate-b/count-mismatch', + severity: 'error', + file: def.file, + loc: `class ${def.name}`, + message: `Typed search found ${count ?? 'unparseable'} indexed ${def.name} instance(s), expected ${expected} — instances exist on disk but are not indexed`, + suggestion: 'Check for a bricking instance (S03 classes) or an indexing job that was silently dropped; re-push per-file', + fixRef: 'boxel-environment/references/indexing-operations.md', + }); + } + } + return findings; + }, +}; diff --git a/scripts/conformance/gates/d05-prerender.mjs b/scripts/conformance/gates/d05-prerender.mjs new file mode 100644 index 00000000..06cdee3d --- /dev/null +++ b/scripts/conformance/gates/d05-prerender.mjs @@ -0,0 +1,60 @@ +// D05 — prerender smoke: the realm's search-prerendered endpoint returns +// rendered HTML (not error payloads) for embedded/fitted/atom of each example. +export default { + id: 'd05-prerender', + title: 'Prerender formats render clean', + phase: 'dynamic', + async run(ctx) { + if (!ctx.options.realm) { + return [{ rule: 'prerender/no-realm', severity: 'warn', file: '.', message: 'D05 skipped: no --realm provided' }]; + } + const realm = ctx.options.realm.endsWith('/') ? ctx.options.realm : `${ctx.options.realm}/`; + const findings = []; + for (const def of ctx.kit.defs) { + if (!def.exported || def.kind !== 'card') continue; + const moduleUrl = `${realm}${def.file.replace(/\.(gts|gjs)$/, '')}`; + for (const format of ['embedded', 'fitted', 'atom']) { + const url = new URL(`${realm}_search-prerendered`); + url.searchParams.set('prerenderedHtmlFormat', format); + url.searchParams.set('filter', JSON.stringify({ type: { module: moduleUrl, name: def.name } })); + try { + const res = await fetch(url, { + headers: { Accept: 'application/vnd.card+json' }, + signal: AbortSignal.timeout(30_000), + }); + const body = await res.text(); + if (!res.ok) { + findings.push({ + rule: 'prerender/http-error', + severity: 'error', + file: def.file, + loc: `${def.name} @${format}`, + message: `Prerender query returned HTTP ${res.status} for ${format}`, + }); + continue; + } + if (/"error"|render error|Encountered error rendering/i.test(body)) { + findings.push({ + rule: 'prerender/render-error', + severity: 'error', + file: def.file, + loc: `${def.name} @${format}`, + message: `Prerendered ${format} contains an error payload — the template throws at render time`, + suggestion: 'Check defensive guards on linksTo/linksToMany traversals and date values', + fixRef: 'boxel/references/defensive-programming.md', + }); + } + } catch (e) { + findings.push({ + rule: 'prerender/unreachable', + severity: 'warn', + file: def.file, + loc: `${def.name} @${format}`, + message: `Prerender endpoint unreachable: ${e.message}`, + }); + } + } + } + return findings; + }, +}; diff --git a/scripts/conformance/gates/s01-structure.mjs b/scripts/conformance/gates/s01-structure.mjs new file mode 100644 index 00000000..32e2c735 --- /dev/null +++ b/scripts/conformance/gates/s01-structure.mjs @@ -0,0 +1,142 @@ +// S01 — listing dir anatomy: one listing JSON, spec coverage, examples, imagery. +import { relationshipsOf, resolveRelative } from '../lib/json-walk.mjs'; + +const FIX = 'catalog-card-factory/references/packaging.md'; + +export default { + id: 's01-structure', + title: 'Listing directory anatomy', + phase: 'static', + run(ctx) { + const findings = []; + const listingPaths = ctx.files.filter((f) => /(^|\/)[A-Za-z]+Listing\/[^/]+\.json$/.test(f)); + + if (listingPaths.length === 0) { + findings.push({ + rule: 'structure/no-listing', + severity: 'error', + file: '.', + message: 'No Listing/*.json found — a listing package must contain exactly one listing instance', + fixRef: FIX, + }); + return findings; + } + if (listingPaths.length > 1) { + findings.push({ + rule: 'structure/multiple-listings', + severity: 'error', + file: listingPaths[1], + message: `Found ${listingPaths.length} listing instances (${listingPaths.join(', ')}) — a listing package must contain exactly one`, + fixRef: FIX, + }); + } + + const listingPath = listingPaths[0]; + const listing = ctx.jsonDocs.get(listingPath)?.doc; + if (!listing) return findings; // unparseable — S02 reports it + const listingType = listing?.data?.meta?.adoptsFrom?.name ?? 'CardListing'; + const rels = relationshipsOf(listing); + const isVisual = listingType === 'CardListing' || listingType === 'AppListing'; + + // linked-file existence for relative listing relationships + const relEntries = Object.entries(rels) + .map(([k, v]) => [k, v?.links?.self]) + .filter(([, link]) => typeof link === 'string' && link.startsWith('.')); + for (const [key, link] of relEntries) { + const resolved = resolveRelative(listingPath, link); + const exists = ctx.files.some((f) => f === resolved || f === `${resolved}.json` || f.startsWith(`${resolved}.`)); + if (!exists && !resolved.startsWith('..')) { + findings.push({ + rule: 'structure/broken-relationship', + severity: 'error', + file: listingPath, + loc: `data.relationships["${key}"]`, + message: `Relationship points at ${link} but no such file exists in the package`, + fixRef: FIX, + }); + } + } + + // examples (visual listings must ship at least one) + const hasExample = Object.keys(rels).some((k) => /^examples\.\d+$/.test(k) && rels[k]?.links?.self); + if (isVisual && !hasExample) { + findings.push({ + rule: 'structure/no-example', + severity: 'error', + file: listingPath, + message: `${listingType} has no examples.N relationships — ship at least one live example instance`, + suggestion: 'Link example instances so the catalog can preview the card with real data', + fixRef: FIX, + }); + } + + // thumbnail + const thumb = rels['cardInfo.cardThumbnail']?.links?.self; + if (!thumb) { + findings.push({ + rule: 'structure/no-thumbnail', + severity: 'error', + file: listingPath, + loc: 'data.relationships["cardInfo.cardThumbnail"]', + message: 'Listing has no thumbnail (cardInfo.cardThumbnail is null)', + fixRef: FIX, + }); + } + + // screenshots (visual listings) + const hasScreenshot = Object.keys(rels).some((k) => /^images\.\d+$/.test(k) && rels[k]?.links?.self); + if (isVisual && !hasScreenshot) { + findings.push({ + rule: 'structure/no-screenshot', + severity: 'error', + file: listingPath, + message: `${listingType} has no images.N screenshot relationships`, + fixRef: FIX, + }); + } + + // name + summary present + const attrs = listing?.data?.attributes ?? {}; + if (!attrs.name) { + findings.push({ + rule: 'structure/no-name', + severity: 'error', + file: listingPath, + message: 'Listing has no name attribute', + }); + } + if (!attrs.summary) { + findings.push({ + rule: 'structure/no-summary', + severity: 'warn', + file: listingPath, + message: 'Listing has no summary — the catalog page will be blank', + }); + } + + // Spec coverage: every module with an exported card/field def should have a local Spec + const specRefs = new Set(); + for (const [rel, entry] of ctx.jsonDocs) { + if (!/(^|\/)Spec\/[^/]+\.json$/.test(rel) || !entry.doc) continue; + const mod = entry.doc?.data?.attributes?.ref?.module; + if (typeof mod === 'string') specRefs.add(resolveRelative(rel, mod)); + } + const defModules = new Set( + ctx.kit.defs.filter((d) => d.exported).map((d) => d.file.replace(/\.(gts|gjs)$/, '')) + ); + for (const mod of defModules) { + if (!specRefs.has(mod)) { + findings.push({ + rule: 'structure/missing-spec', + severity: 'error', + file: `${mod}.gts`, + message: `Module exports a CardDef/FieldDef but has no Spec/*.json with ref.module pointing at it`, + suggestion: 'Every definition module ships a Spec so the catalog can install and document it', + fixRef: FIX, + }); + } + } + + return findings; + }, +}; diff --git a/scripts/conformance/gates/s02-json-validity.mjs b/scripts/conformance/gates/s02-json-validity.mjs new file mode 100644 index 00000000..73d61289 --- /dev/null +++ b/scripts/conformance/gates/s02-json-validity.mjs @@ -0,0 +1,53 @@ +// S02 — every .json parses and has JSON:API card document shape. +import { isInstancePath } from '../lib/json-walk.mjs'; + +export default { + id: 's02-json-validity', + title: 'JSON parse + card document shape', + phase: 'static', + run(ctx) { + const findings = []; + for (const [rel, entry] of ctx.jsonDocs) { + if (!isInstancePath(rel)) continue; + if (entry.error) { + findings.push({ + rule: 'json/unparseable', + severity: 'error', + file: rel, + message: `Invalid JSON: ${entry.error}`, + }); + continue; + } + const d = entry.doc?.data; + if (!d || typeof d !== 'object') { + findings.push({ + rule: 'json/no-data', + severity: 'error', + file: rel, + message: 'Document has no data member — not a JSON:API card document', + }); + continue; + } + if (d.type !== 'card') { + findings.push({ + rule: 'json/wrong-type', + severity: 'error', + file: rel, + loc: 'data.type', + message: `data.type is ${JSON.stringify(d.type)} — must be "card"`, + }); + } + const adopts = d.meta?.adoptsFrom; + if (!adopts || typeof adopts.module !== 'string' || typeof adopts.name !== 'string') { + findings.push({ + rule: 'json/no-adopts-from', + severity: 'error', + file: rel, + loc: 'data.meta.adoptsFrom', + message: 'meta.adoptsFrom must have string module and name — the host cannot load this instance', + }); + } + } + return findings; + }, +}; diff --git a/scripts/conformance/gates/s03-instance-shape.mjs b/scripts/conformance/gates/s03-instance-shape.mjs new file mode 100644 index 00000000..387c82a7 --- /dev/null +++ b/scripts/conformance/gates/s03-instance-shape.mjs @@ -0,0 +1,58 @@ +// S03 — realm-bricking relationship shapes. Ported from +// boxel-workspaces .claude/skills/boxel/scripts/instance-correctness-scan.py (rules 1-2). +import { instances, relationshipsOf } from '../lib/json-walk.mjs'; + +const NONCARD_EXTS = ['.jpg', '.jpeg', '.webp', '.gif', '.pdf', '.mp3', '.mp4', '.wav', '.zip']; +const NONCARD_HOSTS = ['images.unsplash.com', 'images.', 'cdn.', 'img.', 's3.amazonaws.com']; +const FIX = 'boxel/references/base-field-catalog.md'; + +export default { + id: 's03-instance-shape', + title: 'Relationship shapes (realm-bricking classes)', + phase: 'static', + run(ctx) { + const findings = []; + for (const [rel, doc] of instances(ctx)) { + const rels = relationshipsOf(doc); + for (const [key, val] of Object.entries(rels)) { + if (!val || typeof val !== 'object') continue; + const self = val.links?.self; + + // Rule 1: linksToMany serialized as an array under one key + if (Array.isArray(self)) { + findings.push({ + rule: 'instance/linksToMany-array', + severity: 'error', + file: rel, + loc: `data.relationships["${key}"].links.self`, + message: 'linksToMany serialized as an array under links.self; the host rejects this as "not a card resource document"', + suggestion: `Use indexed top-level keys instead: "${key}.0", "${key}.1", …`, + fixRef: FIX, + }); + continue; + } + if (typeof self !== 'string' || !self.startsWith('http')) continue; + + // Rule 2: external non-card URL in a relationship link — bricks indexing + const low = self.toLowerCase().split('?')[0]; + const extHit = NONCARD_EXTS.some((e) => low.endsWith(e)); + const hostHit = NONCARD_HOSTS.some((h) => low.includes(h)); + // .png/.svg are allowed only as RELATIVE realm-file links (ImageDef files); + // over http they are still non-card fetches. + const imgHit = /\.(png|svg)$/.test(low); + if (extHit || hostHit || imgHit) { + findings.push({ + rule: 'instance/external-url-in-link', + severity: 'error', + file: rel, + loc: `data.relationships["${key}"].links.self`, + message: `External non-card URL in relationship link (${self.slice(0, 60)}…) — the indexer fetches it expecting a card, JSON.parse fails on binary, and the whole indexing batch rolls back`, + suggestion: 'Store external URLs in an attributes UrlField (heroImageURL pattern) and keep links.self for card identifiers only', + fixRef: FIX, + }); + } + } + } + return findings; + }, +}; diff --git a/scripts/conformance/gates/s04-no-inline-media.mjs b/scripts/conformance/gates/s04-no-inline-media.mjs new file mode 100644 index 00000000..3b0bfad4 --- /dev/null +++ b/scripts/conformance/gates/s04-no-inline-media.mjs @@ -0,0 +1,37 @@ +// S04 — no inline media/binary payloads in card JSON attributes. +import { instances, attributesOf, walkStrings } from '../lib/json-walk.mjs'; + +const BASE64_BLOB = /^[A-Za-z0-9+/]{512,}={0,2}$/; + +export default { + id: 's04-no-inline-media', + title: 'No inline media / base64 in JSON attributes', + phase: 'static', + run(ctx) { + const findings = []; + for (const [rel, doc] of instances(ctx)) { + walkStrings(attributesOf(doc), (path, str) => { + if (str.startsWith('data:') && str.includes(';base64,')) { + findings.push({ + rule: 'media/data-uri', + severity: 'error', + file: rel, + loc: path, + message: `data: URI embedded in attributes (${str.slice(0, 40)}…) — media must be a realm file linked via FileDef/ImageDef`, + suggestion: 'Write the bytes with WriteBinaryFileCommand and linksTo the file instead', + }); + } else if (str.length >= 512 && BASE64_BLOB.test(str.replace(/\s/g, ''))) { + findings.push({ + rule: 'media/base64-blob', + severity: 'error', + file: rel, + loc: path, + message: `Attribute contains a ${str.length}-char base64-looking blob — inline binary is forbidden in card JSON`, + suggestion: 'Store as a realm file and link it (FileDef/ImageDef/PngDef)', + }); + } + }); + } + return findings; + }, +}; diff --git a/scripts/conformance/gates/s05-relative-refs.mjs b/scripts/conformance/gates/s05-relative-refs.mjs new file mode 100644 index 00000000..78e6feb2 --- /dev/null +++ b/scripts/conformance/gates/s05-relative-refs.mjs @@ -0,0 +1,105 @@ +// S05 — reference hygiene: no localhost, no leftover dev/user-realm URLs from +// packaging, intra-package references relative. Absolute URLs are legal ONLY +// for the base realm, the catalog realm itself, boxel-icons, and bare npm-style +// specifiers (@cardstack/*, ember, @glimmer, @ember, tracked-built-ins, lodash, etc.). +import { instances, relationshipsOf } from '../lib/json-walk.mjs'; +import { importDecls } from '../lib/gts-scan.mjs'; + +const ALLOWED_URL_PREFIXES = [ + 'https://cardstack.com/base/', + 'https://app.boxel.ai/catalog/', + 'https://realms-staging.stack.cards/catalog/', + 'https://stack.cards/catalog/', + 'https://boxel-icons.boxel.ai/', +]; + +function classifyUrl(url) { + if (/localhost|127\.0\.0\.1/.test(url)) return 'localhost'; + if (!/^https?:\/\//.test(url)) return 'not-absolute'; + if (ALLOWED_URL_PREFIXES.some((p) => url.startsWith(p))) return 'allowed'; + if (/^https:\/\/(app\.boxel\.ai|realms-staging\.stack\.cards|stack\.cards)\//.test(url)) { + return 'foreign-realm'; // a realm host but NOT the catalog realm — dev/user realm leftover + } + return 'external'; +} + +export default { + id: 's05-relative-refs', + title: 'Reference hygiene (relative refs, no dev-realm leftovers)', + phase: 'static', + run(ctx) { + const findings = []; + + // JSON: adoptsFrom modules + relationship links + for (const [rel, doc] of instances(ctx)) { + const spots = []; + const mod = doc?.data?.meta?.adoptsFrom?.module; + if (typeof mod === 'string') spots.push(['data.meta.adoptsFrom.module', mod]); + for (const [key, val] of Object.entries(relationshipsOf(doc))) { + const self = val?.links?.self; + if (typeof self === 'string') spots.push([`data.relationships["${key}"].links.self`, self]); + } + for (const [loc, url] of spots) { + const kind = classifyUrl(url); + if (kind === 'localhost') { + findings.push({ + rule: 'refs/localhost', + severity: 'error', + file: rel, + loc, + message: `localhost URL (${url.slice(0, 60)}) — will not resolve in the catalog realm`, + }); + } else if (kind === 'foreign-realm') { + findings.push({ + rule: 'refs/dev-realm-leftover', + severity: 'error', + file: rel, + loc, + message: `URL points at a non-catalog realm (${url.slice(0, 70)}…) — packaging did not rewrite this reference`, + suggestion: 'Re-run packaging (collect-submission-files rewrites realm URLs to relative paths)', + fixRef: 'catalog-card-factory/references/packaging.md', + }); + } + } + } + + // GTS imports + for (const [rel, src] of ctx.gtsSources) { + for (const imp of importDecls(src)) { + if (!/^https?:\/\//.test(imp.source)) { + if (/localhost|127\.0\.0\.1/.test(imp.source)) { + findings.push({ + rule: 'refs/localhost', + severity: 'error', + file: rel, + loc: `line ${imp.line}`, + message: `localhost import (${imp.source})`, + }); + } + continue; // relative or bare specifier — fine + } + const kind = classifyUrl(imp.source); + if (kind === 'foreign-realm') { + findings.push({ + rule: 'refs/dev-realm-leftover', + severity: 'error', + file: rel, + loc: `line ${imp.line}`, + message: `Import from a non-catalog realm (${imp.source.slice(0, 70)}…) — packaging did not rewrite this module reference`, + fixRef: 'catalog-card-factory/references/packaging.md', + }); + } else if (kind === 'external') { + findings.push({ + rule: 'refs/external-module', + severity: 'warn', + file: rel, + loc: `line ${imp.line}`, + message: `Import from external host (${imp.source.slice(0, 70)}) — CDN imports are allowed but must be deliberate`, + suggestion: 'If this is a library, consider the integrate-*-via-cdn patterns; otherwise vendor it into the package', + }); + } + } + } + return findings; + }, +}; diff --git a/scripts/conformance/gates/s06-date-shape.mjs b/scripts/conformance/gates/s06-date-shape.mjs new file mode 100644 index 00000000..416095bc --- /dev/null +++ b/scripts/conformance/gates/s06-date-shape.mjs @@ -0,0 +1,58 @@ +// S06 — DateField vs DateTimeField schema↔instance contract. Ported from +// instance-correctness-scan.py rule 3. A mismatch passes lint, writes, indexes — +// then crashes at render with RangeError: Invalid time value. +import { instances, attributesOf, resolveRelative } from '../lib/json-walk.mjs'; +import { dateFieldMap } from '../lib/gts-scan.mjs'; + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d{1,3})?(Z|[+-]\d{2}:?\d{2})?$/; +const FIX = 'boxel/references/base-field-catalog.md'; + +export default { + id: 's06-date-shape', + title: 'DateField / DateTimeField value shapes', + phase: 'static', + run(ctx) { + // field-type maps per module rel path (without extension) + const maps = new Map(); + for (const [rel, src] of ctx.gtsSources) { + maps.set(rel.replace(/\.(gts|gjs)$/, ''), dateFieldMap(src)); + } + + const findings = []; + for (const [rel, doc] of instances(ctx)) { + const mod = doc?.data?.meta?.adoptsFrom?.module; + if (typeof mod !== 'string' || !mod.startsWith('.')) continue; + const resolved = resolveRelative(rel, mod); + const fieldMap = maps.get(resolved); + if (!fieldMap) continue; + const attrs = attributesOf(doc); + for (const [fname, ftype] of Object.entries(fieldMap)) { + const v = attrs[fname]; + if (typeof v !== 'string' || v === '') continue; + if (ftype === 'DateField' && !DATE_RE.test(v)) { + findings.push({ + rule: 'date/datefield-shape', + severity: 'error', + file: rel, + loc: `data.attributes.${fname}`, + message: `${fname}=${JSON.stringify(v)} but the schema declares contains(DateField) — value must be YYYY-MM-DD with no T`, + suggestion: 'Either reformat the value or switch the field to DateTimeField if time-of-day matters', + fixRef: FIX, + }); + } else if (ftype === 'DateTimeField' && !DATETIME_RE.test(v)) { + findings.push({ + rule: 'date/datetimefield-shape', + severity: 'error', + file: rel, + loc: `data.attributes.${fname}`, + message: `${fname}=${JSON.stringify(v)} but the schema declares contains(DateTimeField) — value must be an ISO datetime containing T`, + suggestion: 'Either reformat the value or switch the field to DateField if time-of-day is meaningless', + fixRef: FIX, + }); + } + } + } + return findings; + }, +}; diff --git a/scripts/conformance/gates/s07-formats-present.mjs b/scripts/conformance/gates/s07-formats-present.mjs new file mode 100644 index 00000000..40d4c87d --- /dev/null +++ b/scripts/conformance/gates/s07-formats-present.mjs @@ -0,0 +1,42 @@ +// S07 — every exported CardDef ships isolated + embedded + fitted; FieldDefs +// should override embedded or edit (base defaults are rarely adequate for a listing). +import { staticFormats } from '../lib/gts-scan.mjs'; + +const FIX = 'boxel/references/design-playbook.md'; + +export default { + id: 's07-formats-present', + title: 'Required formats per definition', + phase: 'static', + run(ctx) { + const findings = []; + for (const def of ctx.kit.defs) { + if (!def.exported) continue; + const formats = staticFormats(def.body); + if (def.kind === 'card') { + for (const required of ['isolated', 'embedded', 'fitted']) { + if (!formats[required]) { + findings.push({ + rule: 'formats/missing-card-format', + severity: 'error', + file: def.file, + loc: `class ${def.name} (line ${def.line})`, + message: `CardDef ${def.name} has no static ${required} template — every catalog card needs isolated, embedded AND fitted`, + suggestion: 'Utility/config cards may waive this with a reason', + fixRef: FIX, + }); + } + } + } else if (!formats.embedded && !formats.edit) { + findings.push({ + rule: 'formats/fielddef-no-override', + severity: 'warn', + file: def.file, + loc: `class ${def.name} (line ${def.line})`, + message: `FieldDef ${def.name} overrides neither embedded nor edit — it will render with bare base defaults`, + }); + } + } + return findings; + }, +}; diff --git a/scripts/conformance/gates/s08-fitted-cq.mjs b/scripts/conformance/gates/s08-fitted-cq.mjs new file mode 100644 index 00000000..28264287 --- /dev/null +++ b/scripts/conformance/gates/s08-fitted-cq.mjs @@ -0,0 +1,45 @@ +// S08 — fitted templates must use container queries (the CQ .cq→.fit discipline); +// the parent owns the cell size, so fixed root heights are suspect. +import { staticFormats, styleBlocks } from '../lib/gts-scan.mjs'; + +const FIX = 'boxel/references/container-query-fitted-layout.md'; + +export default { + id: 's08-fitted-cq', + title: 'Container-query fitted layouts', + phase: 'static', + run(ctx) { + const findings = []; + for (const def of ctx.kit.defs) { + if (def.kind !== 'card' || !def.exported) continue; + const fitted = staticFormats(def.body).fitted; + if (!fitted) continue; // S07's problem + const styles = styleBlocks(fitted).join('\n'); + if (!styles.includes('@container')) { + // warn, not error: trivially-scaling fitted layouts (e.g. a full-bleed + // image tile) are legitimately CQ-free; the factory adjudicates. + findings.push({ + rule: 'fitted/no-container-query', + severity: 'warn', + file: def.file, + loc: `class ${def.name} static fitted`, + message: `${def.name}'s fitted template has no @container queries — fitted cells range from 100px badges to 800px tiles and one fixed layout cannot serve them`, + suggestion: 'Use the two-element .cq → .fit structure with container-query sub-formats', + fixRef: FIX, + }); + } + const fixedRoot = styles.match(/^\s*(height|width)\s*:\s*\d{2,}px/m); + if (fixedRoot) { + findings.push({ + rule: 'fitted/fixed-dimension', + severity: 'warn', + file: def.file, + loc: `class ${def.name} static fitted`, + message: `Fitted styles set a fixed ${fixedRoot[1]} in px — the parent owns the cell size; fixed dimensions overflow or underfill`, + fixRef: FIX, + }); + } + } + return findings; + }, +}; diff --git a/scripts/conformance/gates/s09-thunk-links.mjs b/scripts/conformance/gates/s09-thunk-links.mjs new file mode 100644 index 00000000..9ebebbd5 --- /dev/null +++ b/scripts/conformance/gates/s09-thunk-links.mjs @@ -0,0 +1,31 @@ +// S09 — kit-internal linksTo/linksToMany targets must be thunk-wrapped: +// `linksTo(() => Task)`. The bare form fails at runtime with +// "cardOrThunk was undefined" on module cycles — invisible to lint and TS. +import { fieldDecls } from '../lib/gts-scan.mjs'; + +export default { + id: 's09-thunk-links', + title: 'Thunk-wrapped kit-internal relationships', + phase: 'static', + run(ctx) { + const findings = []; + const localClasses = new Set(ctx.kit.classIndex.keys()); + for (const [rel, src] of ctx.gtsSources) { + for (const decl of fieldDecls(src)) { + if (decl.kind !== 'linksTo' && decl.kind !== 'linksToMany') continue; + if (decl.thunk || !decl.target) continue; + if (localClasses.has(decl.target)) { + findings.push({ + rule: 'links/bare-kit-internal', + severity: 'error', + file: rel, + loc: `line ${decl.line}`, + message: `@field ${decl.name} = ${decl.kind}(${decl.target}) references a kit-internal class without a thunk — module cycles make this fail at runtime with "cardOrThunk was undefined"`, + suggestion: `Write ${decl.kind}(() => ${decl.target})`, + }); + } + } + } + return findings; + }, +}; diff --git a/scripts/conformance/gates/s10-theme-vars.mjs b/scripts/conformance/gates/s10-theme-vars.mjs new file mode 100644 index 00000000..1a49e2a1 --- /dev/null +++ b/scripts/conformance/gates/s10-theme-vars.mjs @@ -0,0 +1,46 @@ +// S10 — theme-token discipline: scoped styles should use var(--*) tokens, not +// color literals. Warning severity (adjudicate: fix or waive) — data-driven +// colors in attributes are NOT flagged; only CSS inside }; From e2a75704754dde1a50e44d40dad24bc59e38496d Mon Sep 17 00:00:00 2001 From: tintinthong Date: Mon, 6 Jul 2026 16:36:00 +0800 Subject: [PATCH 3/3] Document boundary with monorepo software-factory package Co-Authored-By: Claude Fable 5 --- scripts/conformance/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/conformance/README.md b/scripts/conformance/README.md index 4ba9041e..1a3927ae 100644 --- a/scripts/conformance/README.md +++ b/scripts/conformance/README.md @@ -54,6 +54,18 @@ finding carries `rule`, `severity`, `file`, `loc`, `message`, `suggestion`, and `fixRef` (a pointer into the card-factory skill tree), so an agent can consume the report as a fix-list. +## Relationship to `software-factory` (boxel monorepo) + +`packages/software-factory` validates cards *functionally* against a **live realm** +(parse, realm lint, module eval, instantiate-from-Spec, QUnit) as part of its +autonomous in-realm build loop. This harness is deliberately different: static, +repo-local, zero-infra checks of **catalog-semantic** rules (realm-bricking JSON +shapes, date contracts, thunk links, theme tokens, CQ fitted layouts, icon CDN, +reuse inventory, slop tells) that have no counterpart there. The only echo is +our dynamic D01–D03 wrappers hitting the same realm endpoints their validators +use. Keep the boundary: in-realm functional validation belongs in +software-factory; listing-dir catalog conformance belongs here. + ## Extending One module per gate in `gates/`, uniform interface `{ id, title, phase, run(ctx) }`