Skip to content
Merged
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,13 @@ jobs:
- 'skills/**'
- '.github/workflows/scaffold-e2e.yml'
- '.claude/skills/spec-property-retirement/SKILL.md'
# @objectstack/lint's production-witness suite reads the repo-root
# SDUI manifest (#12924) — verbatim per the declaration in
# scripts/cross-package-test-inputs.mjs (check:ci-filter-parity
# holds this list to the declarations; the examples/ page files
# the same suite declares are covered by entries the core filter
# already carries).
- 'sdui.manifest.json'

test:
# Sharded 6-way BY PACKAGE: a core-touching PR ran the affected suite
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,23 @@ jobs:
- name: sdui-parser stays in lockstep with objectui's copy
run: pnpm check:sdui-lockstep

# The checked-in SDUI public-tier manifest (#12924, ruled 2026-08-29: wire
# it). `resolveSduiManifest()` degrades to parse-only SILENTLY when the
# repo-root `sdui.manifest.json` is absent or unreadable, so nothing in
# the production path ever says "the gate lost its whitelist" — this step
# is the thing that notices. Offline record checks only (existence, shape,
# sha256 vs the provenance record, record pin vs `.objectui-sha`): the
# regenerate-and-compare half runs where a regeneration can (the Node
# generator needs the published @object-ui set — see
# scripts/gen-sdui-manifest-node.mjs), and a pin bump goes red HERE until
# it has been re-run — the same moment the lockstep step above forces the
# parser parity re-verification, with an objectui checkout guaranteed in
# hand. Unfiltered for the same reason as its neighbour: filtering to the
# artefact would go dormant on exactly the PR that moves the pin.
# Direct invocation per the GATE INVOCATION IDIOM at the top of this file.
- name: SDUI manifest is present, intact and fresh at the objectui pin
run: node scripts/check-sdui-manifest.mjs --self-test && node scripts/check-sdui-manifest.mjs

# pm-dispatch SKILL.md line ratchet (#7341 / #5925 item 7): the PM skill
# is read in full by every seat session and every Routine fire, so its
# size is a per-round tax on the whole fleet. The #7341 extraction moved
Expand Down Expand Up @@ -4747,6 +4764,18 @@ jobs:
- name: Check docs YAML examples parse against live spec schemas
run: pnpm --filter @objectstack/spec run check:yaml-examples

# ADR-0082 D4's spec↔registry react-block declaration-parity ratchet, fed
# by the checked-in repo-root manifest (#12924 execution point 1: the same
# artefact resolves this gate's EXTERNAL_INPUT_REQUIRED standing — the
# input it always lacked now lives in the tree, held fresh by the
# check-sdui-manifest step in the lint job). Baseline-ratcheted: only a
# divergence NEW since packages/spec/react-declaration-parity.baseline.json
# reds, so wiring it per-PR promotes the cut-rc-only run without
# manufacturing a standing red. Reads spec src/ through tsx like its
# neighbours — no dist. ~5 s.
- name: Ratchet spec-vs-registry react declaration parity against the checked-in manifest
run: MANIFEST="$PWD/sdui.manifest.json" pnpm --filter @objectstack/spec check:react-declaration-parity --baseline react-declaration-parity.baseline.json --strict

# Same anti-drift class as the gates above, for the generated translation
# bundles in packages/platform-objects/src/apps/translations/. Nothing
# regenerated them either, so they rotted three ways at once (#3670):
Expand Down
39 changes: 39 additions & 0 deletions packages/lint/src/sdui-jsx-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"//": [
"RATCHET-TO-ZERO ledger for the first live run of the wired SDUI JSX gate (#12924,",
"ruled 2026-08-29: wire it; first-wiring noise handled per ui#6779's ratchet-to-zero —",
"itemized, dated, visible, and NOT an exemption). Every row is a diagnostic the wired",
"`validateJsxPages` (real repo-root sdui.manifest.json, real shipped pages) emits TODAY.",
"",
"Direction 1 — a finding NOT covered by a row is a NEW violation: fix the page (or, if",
"the vocabulary legitimately grew, regenerate the manifest); never grow this file.",
"Direction 2 — a row the live run no longer produces is a STALE entry: delete it in the",
"same PR that fixed the page (or that landed the vocabulary the tags were waiting for).",
"The ledger only shrinks. Zero rows = delete the file and the census assertion inverts",
"to 'wired run is clean'.",
"",
"Why these 232 exist at all: the public tier deliberately declares no intrinsic HTML",
"tags (57 curated blocks; div/a/p absent; `html` is the tier-C escape hatch), while the",
"three shipped html pages author with div/a — every error below is a plain HTML tag,",
"none is a real authoring mistake (measured 2026-08-29, re-measured 2026-08-30). The",
"not-a-container rows are objectui#6740 (flex registers without isContainer). What the",
"html tier's legal vocabulary IS remains a producer-side question (objectui); this",
"ledger keeps the gate live and honest while that question settles."
],
"recordedAt": "2026-08-30",
"findings": [
{ "page": "showcase_capability_map", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "a", "count": 17 },
{ "page": "showcase_capability_map", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "div", "count": 23 },
{ "page": "showcase_capability_map", "rule": "jsx-not-a-container", "severity": "warning", "tag": "flex", "count": 14 },
{ "page": "showcase_capability_map", "rule": "jsx-unknown-component", "severity": "error", "tag": "a", "count": 17 },
{ "page": "showcase_capability_map", "rule": "jsx-unknown-component", "severity": "error", "tag": "div", "count": 23 },
{ "page": "showcase_command_center_jsx", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "div", "count": 29 },
{ "page": "showcase_command_center_jsx", "rule": "jsx-not-a-container", "severity": "warning", "tag": "flex", "count": 12 },
{ "page": "showcase_command_center_jsx", "rule": "jsx-unknown-component", "severity": "error", "tag": "div", "count": 29 },
{ "page": "showcase_start_here", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "a", "count": 4 },
{ "page": "showcase_start_here", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "div", "count": 27 },
{ "page": "showcase_start_here", "rule": "jsx-not-a-container", "severity": "warning", "tag": "flex", "count": 6 },
{ "page": "showcase_start_here", "rule": "jsx-unknown-component", "severity": "error", "tag": "a", "count": 4 },
{ "page": "showcase_start_here", "rule": "jsx-unknown-component", "severity": "error", "tag": "div", "count": 27 }
]
}
164 changes: 164 additions & 0 deletions packages/lint/src/validate-jsx-pages.production-witness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Production-path witness + first-wiring ratchet for the SDUI JSX gate
// (#12924, maintainer ruling 2026-08-29: wire it; execution point 3 demands a
// witness that REALLY PARSES the checked-in manifest into `validateTree`).
//
// ── Why this file exists, stated as the blind spot it closes ──────────────
//
// Every other guard on `validateTree` in this repo constructs its manifest
// IN MEMORY, so a green suite was compatible with the production gate being
// parse-only for the whole life of the code — no test resolved a real
// artefact, because there was nothing to resolve (#12924's finding). These
// tests read the REAL repo-root `sdui.manifest.json` from DISK, feed it
// through the REAL production entry points, and pin the arming delta itself.
//
// Two cross-package inputs, both declared so the graph can see them
// (`check:examples-live-imports`, `@objectstack/lint#test` inputs in
// turbo.json): the repo-root artefact, and the three shipped html pages.

import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

import { validateJsxPages } from './validate-jsx-pages.js';
import { runAuthoringRules } from './authoring-rules.js';

import { CapabilityMapPage } from '../../../examples/app-showcase/src/ui/pages/capability-map.page.js';
import { CommandCenterJsxPage } from '../../../examples/app-showcase/src/ui/pages/command-center-jsx.page.js';
import { StartHerePage } from '../../../examples/app-showcase/src/ui/pages/start-here.page.js';

const HERE = dirname(fileURLToPath(import.meta.url));

interface LedgerRow {
page: string;
rule: string;
severity: string;
tag: string;
count: number;
}
// readFileSync rather than a JSON module import: under NodeNext the latter
// needs an import attribute (TS1543), which would add to this package's frozen
// TEST_DEBT tsc count for no behavioral gain.
const ledger = JSON.parse(readFileSync(join(HERE, 'sdui-jsx-baseline.json'), 'utf8')) as {
findings: LedgerRow[];
};

/** Walk up to the workspace root — the directory holding pnpm-workspace.yaml. */
function findUp(predicate: (dir: string) => boolean): string {
let dir = HERE;
for (;;) {
if (predicate(dir)) return dir;
const parent = dirname(dir);
if (parent === dir) throw new Error('workspace root not found from ' + HERE);
dir = parent;
}
}
const REPO = findUp((dir) => existsSync(join(dir, 'pnpm-workspace.yaml')));

// The artefact, from DISK — the same bytes `resolveSduiManifest()` (packages/
// cli, path 1: join(process.cwd(), 'sdui.manifest.json')) picks up when the
// gate runs from the repo root. Loud absence: an absent artefact silently
// reverts production to parse-only, so this read failing IS the regression.
const ARTEFACT = join(REPO, 'sdui.manifest.json');
const manifest = JSON.parse(readFileSync(ARTEFACT, 'utf8'));

describe('production witness: the checked-in manifest reaches validateTree', () => {
it('is the real artefact (57-component public tier, no intrinsic HTML tags)', () => {
const keys = Object.keys(manifest.components);
expect(keys.length).toBeGreaterThan(0);
// The vocabulary facts the ratchet below stands on. If a regeneration
// legitimately changes them, the ledger is re-derived in the same PR.
expect(keys).toContain('flex');
expect(keys).toContain('html');
expect(keys).not.toContain('div');
});

it('arms full validation through validateJsxPages: manifest-only diagnostics fire', () => {
const stack = {
pages: [
{
name: 'witness_page',
kind: 'html',
// `flex` is a real public component; `no-such-block` is not. Only
// validateTree (fed by the DISK manifest) can tell them apart —
// parse-only cannot emit unknown-component at all.
source: '<flex direction="col" notARealProp="x"><no-such-block /></flex>',
},
],
};
const wired = validateJsxPages(stack, { manifest });
const rules = new Set(wired.map((f) => f.rule));
expect(rules).toContain('jsx-unknown-component'); // no-such-block, judged by the manifest
expect(rules).toContain('jsx-unknown-prop'); // notARealProp on flex, judged by flex's declared inputs

// The arming delta itself: the SAME stack, parse-only, emits neither.
const parseOnly = validateJsxPages(stack);
const parseOnlyRules = new Set(parseOnly.map((f) => f.rule));
expect(parseOnlyRules).not.toContain('jsx-unknown-component');
expect(parseOnlyRules).not.toContain('jsx-unknown-prop');
});

it('threads through the production registry entry (runAuthoringRules ctx.sduiManifest)', () => {
// The same seam `os validate`/`os build`/`os lint` drive: authoring-rules'
// validateJsxPages entry reads ctx.sduiManifest — never a lookalike call.
const stack = {
pages: [{ name: 'witness_page', kind: 'html', source: '<no-such-block />' }],
};
const withManifest = runAuthoringRules('validate', {
normalized: stack,
sduiManifest: manifest,
});
expect(withManifest.some((f) => f.rule === 'jsx-unknown-component')).toBe(true);

const without = runAuthoringRules('validate', { normalized: stack });
expect(without.some((f) => f.rule === 'jsx-unknown-component')).toBe(false);
});
});

describe('first-wiring ratchet: the shipped pages against the wired gate (ui#6779 ratchet-to-zero)', () => {
it('wired census over the three shipped html pages equals the ledger — both directions', () => {
const stack = { pages: [CapabilityMapPage, CommandCenterJsxPage, StartHerePage] };
const findings = validateJsxPages(stack as never, { manifest });

const census = new Map<string, number>();
for (const f of findings) {
const page = /page "([^"]+)"/.exec(f.where)?.[1] ?? '(unknown page)';
const tag = /<([a-zA-Z0-9:_-]+)>/.exec(f.where)?.[1] ?? '(no tag)';
const key = `${page}|${f.rule}|${f.severity}|${tag}`;
census.set(key, (census.get(key) ?? 0) + 1);
}

const recorded = new Map<string, number>(
ledger.findings.map((r): [string, number] => [`${r.page}|${r.rule}|${r.severity}|${r.tag}`, r.count]),
);

const newViolations: string[] = [];
for (const [key, count] of census) {
const allowed = recorded.get(key) ?? 0;
if (count > allowed) newViolations.push(`${key} — live ${count} vs ledger ${allowed}`);
}
const stale: string[] = [];
for (const [key, count] of recorded) {
const live = census.get(key) ?? 0;
if (live < count) stale.push(`${key} — ledger ${count} vs live ${live}`);
}

expect(
newViolations,
'NEW wired-gate findings beyond the ratchet ledger. Fix the page (or regenerate the manifest ' +
'if the vocabulary legitimately grew) — never grow packages/lint/src/sdui-jsx-baseline.json.',
).toEqual([]);
expect(
stale,
'STALE ledger rows — the live run no longer produces them. Ratchet-to-zero: delete these rows ' +
'from packages/lint/src/sdui-jsx-baseline.json in this same PR.',
).toEqual([]);
});

it('parse-only over the same pages stays clean (today\'s pre-wiring behavior, pinned)', () => {
const stack = { pages: [CapabilityMapPage, CommandCenterJsxPage, StartHerePage] };
expect(validateJsxPages(stack as never)).toEqual([]);
});
});
18 changes: 13 additions & 5 deletions scripts/check-ci-filter-parity.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -603,14 +603,18 @@ export async function selfTest() {
// single-file `crosspkg` entry the way #10848's SKILL.md is. Plus, since
// #12201, the one declaration under the `skills/**` root that card added
// (the export-list corpus gate reads the published catalog from inside
// @objectstack/spec). Ten plus one plus two plus one: the rollback now
// uncovers fourteen. This pin is judged over the LIVE declaration table on
// @objectstack/spec). Plus, since #12924, the one repo-root declaration that
// card added (the checked-in SDUI manifest artefact @objectstack/lint's
// production-witness suite reads from the workspace root), covered only
// through its own single-file `crosspkg` entry the way #10848's SKILL.md is.
// Ten plus one plus two plus one plus one: the rollback now uncovers
// fifteen. This pin is judged over the LIVE declaration table on
// purpose: a declaration added under a root the rollback keeps leaves the
// count alone, one under a new root moves it and is recorded here by name.
const preFix = judge(fixtureWorkflow({ core: real.filters?.core, crosspkg: ['scripts/**'] }), CROSS_PACKAGE_TEST_INPUTS);
assert(
new Set(uncoveredGlobs(preFix)).size === 14,
`rolling \`crosspkg\` back to its pre-#10015 list uncovers the ten it fixed plus #10848's one plus #10178's two plus #12201's one -- got ${new Set(uncoveredGlobs(preFix)).size}`,
new Set(uncoveredGlobs(preFix)).size === 15,
`rolling \`crosspkg\` back to its pre-#10015 list uncovers the ten it fixed plus #10848's one plus #10178's two plus #12201's one plus #12924's one -- got ${new Set(uncoveredGlobs(preFix)).size}`,
);
assert(
uncoveredGlobs(preFix).includes('skills/**'),
Expand All @@ -628,6 +632,10 @@ export async function selfTest() {
uncoveredGlobs(preFix).includes('skills/objectstack-automation/SKILL.md'),
`-- and #10178 added the automation skill file, by name`,
);
assert(
uncoveredGlobs(preFix).includes('sdui.manifest.json'),
`-- and #12924 added the repo-root SDUI manifest artefact, by name`,
);

// ── (7) WIRING: the gate and its self-test really run in CI ──────────────
const SELF = 'scripts/check-ci-filter-parity.mjs';
Expand All @@ -652,7 +660,7 @@ export async function selfTest() {
`same-root-different-file case observed failing and then covered by naming the file, a glob covered by ` +
`\`core\`, one covered only by \`crosspkg\` and one covered by neither judged separately in one table, the ` +
`stale-entry direction, seven refusals over subjects that could not be read, the checked-in ci.yml, the ` +
`pre-#10015 rollback uncovering the ten it fixed plus #10848's one plus #10178's two plus #12201's one, ` +
`pre-#10015 rollback uncovering the ten it fixed plus #10848's one plus #10178's two plus #12201's one plus #12924's one, ` +
`and the CI wiring read out of lint.yml.`,
);
return 0;
Expand Down
Loading
Loading