Skip to content
Open
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
3 changes: 3 additions & 0 deletions .boxelignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ LICENSE
tests
.github
scripts
.conformance-waivers.yaml
.conformance-report.json
.boxel-history
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
.claude/
.claude/
.conformance-report.json
**/.conformance-report.json
.boxel-history/
75 changes: 75 additions & 0 deletions scripts/conformance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Listing conformance harness

Machine-checkable quality gates for catalog listing packages. Run it on any
top-level listing directory (one containing a `<Type>Listing/*.json`):

```
node scripts/conformance/run.mjs <listing-dir> \
[--phase static|full] # default static; full adds boxel-cli / realm gates
[--realm <url>] # dev realm for the dynamic gates (D02-D05)
[--json <out.json>] # report path (default <dir>/.conformance-report.json, gitignored)
[--waivers <file.yaml>] # default <dir>/.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

`<listing-dir>/.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.

## 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) }`
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`.
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
}
44 changes: 44 additions & 0 deletions scripts/conformance/fixtures/broken-listing/Event/gala.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
}
48 changes: 48 additions & 0 deletions scripts/conformance/fixtures/broken-listing/event.gts
Original file line number Diff line number Diff line change
@@ -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<typeof Event> {
<template>
<div class='hero'><h1>{{@model.title}}</h1></div>
<style scoped>
.hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>
</template>
};

static embedded = class extends Component<typeof Event> {
<template>
<span>{{@model.title}}</span>
</template>
};

static fitted = class extends Component<typeof Event> {
<template>
<div class='tile'>{{@model.title}}</div>
<style scoped>
.tile {
padding: 4px;
}
</style>
</template>
};
}
36 changes: 36 additions & 0 deletions scripts/conformance/fixtures/broken-listing/venue.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// 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<typeof Venue> {
<template>
<h1>{{@model.name}}</h1>
</template>
};
static embedded = class extends Component<typeof Venue> {
<template>
<span>{{@model.name}}</span>
</template>
};
static fitted = class extends Component<typeof Venue> {
<template>
<div class='v'>{{@model.name}}</div>
<style scoped>
@container card (height <= 80px) {
.v {
font-size: 11px;
}
}
</style>
</template>
};
}
35 changes: 35 additions & 0 deletions scripts/conformance/gates/d01-boxel-parse.mjs
Original file line number Diff line number Diff line change
@@ -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),
}));
},
};
51 changes: 51 additions & 0 deletions scripts/conformance/gates/d02-boxel-lint.mjs
Original file line number Diff line number Diff line change
@@ -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;
},
};
43 changes: 43 additions & 0 deletions scripts/conformance/gates/d03-module-load.mjs
Original file line number Diff line number Diff line change
@@ -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;
},
};
Loading
Loading