diff --git a/.changeset/check-schema-positive-marker-and-skip-count.md b/.changeset/check-schema-positive-marker-and-skip-count.md new file mode 100644 index 000000000..84f3ac9f6 --- /dev/null +++ b/.changeset/check-schema-positive-marker-and-skip-count.md @@ -0,0 +1,39 @@ +--- +'@object-ui/cli': minor +--- + +`objectui check` judges a file's `type` only when the file is recognisable as an ObjectUI schema, and reports how many it declined to judge. + +A root `type` was treated as a component key wherever it appeared. `type` heads at +least seven unrelated JSON vocabularies, and the most common of them is +`package.json`'s `"type": "module"` — so the first line a user saw running +`objectui check` in their own project was a warning about their own package +manifest. Measured at this repository's root: 46 warnings, 45 of them +`package.json` (objectui#5127). + +A file now enters type judgement only when its root carries a structural key +declared on `BaseSchema` — `children`, `body`, `className`, `placeholder`, +`style`, the `visible`/`hidden`/`disabled` predicate family, `testId`, +`ariaLabel`. Every other root-`type` vocabulary — JSON Schema's `"array"`, an +`.eslintrc.json`'s `"commonjs"`, a package manifest's `"module"` — is simply +never judged. The key set is read out of the node contract rather than invented, +and it is closed: it grows only when `BaseSchema` grows. + +A list of filenames to exclude was the alternative and was rejected: it is a +second hand-maintained list of the shape objectui#5115 had just finished +deleting, and it can only ever enumerate the foreign vocabularies someone already +thought of. This is a positive marker instead. + +Because the marker narrows what is checked, the command now also reports the +count of files that had a root `type` and no marker, together with the marker +keys that opt one back in. That number is the coverage this gate gives up until +schema files are recognisable, and printing it is what keeps the loss visible +rather than silent. The `.yaml`/`.yml` half of the scan is unchanged — it was +never type-judged, before this change or after it. Exit codes are untouched: a +JSON parse failure remains the only thing that fails the run. + +No public `$schema` URL is introduced. An earlier revision also admitted a file +whose root `$schema` had an `objectui.org` host; the maintainer ruled against +minting that identifier (2026-08-20, objectui#5127), so the structural key is the +only marker. Because the matching was host-based rather than literal, that arm +can be added later without invalidating a single file. diff --git a/packages/cli/src/__tests__/check-jsonc-parse.test.ts b/packages/cli/src/__tests__/check-jsonc-parse.test.ts index 444ce4757..c53169954 100644 --- a/packages/cli/src/__tests__/check-jsonc-parse.test.ts +++ b/packages/cli/src/__tests__/check-jsonc-parse.test.ts @@ -194,9 +194,24 @@ describe('objectui check — genuinely malformed JSON still fails the run', () = }); }); +/** + * These fixtures carry `className` — a STRUCTURAL marker key — because + * objectui#5127 gated the warning arm behind a positive ObjectUI marker: a + * bare `{"type": ...}` file is no longer judged at all, so without a marker + * every assertion below would pass for the wrong reason — including the two + * that assert SILENCE, which would then be measuring nothing. + * + * An earlier revision declared a `$schema` URL here instead. The maintainer's + * 2026-08-20 ruling removed that arm, so those fixtures would have been + * admitted by nothing at all while every assertion in this section stayed + * green. Each silence assertion below is now paired with a counter-probe that + * proves judgement actually ran. The gate itself is pinned in + * `check-schema-marker.test.ts`; this section is about the warning arm the gate + * admits files to. + */ describe('objectui check — the unknown-type warning arm is untouched (objectui#5127)', () => { it('still warns for an unrecognised root type, and still does not fail the run', async () => { - writeFile('bogus.json', '{"type":"totally-made-up-xyz"}'); + writeFile('bogus.json', '{"className":"p-0","type":"totally-made-up-xyz"}'); await check(cwd); @@ -210,7 +225,10 @@ describe('objectui check — the unknown-type warning arm is untouched (objectui // Files that previously died at the parse step now reach the type check — // the warning arm's reach grows, but its verdict and its exit-code // neutrality are unchanged. - writeFile('commented.json', '{\n // a comment\n "type": "totally-made-up-xyz",\n}\n'); + writeFile( + 'commented.json', + '{\n // a comment\n "className": "p-0",\n "type": "totally-made-up-xyz",\n}\n' + ); await check(cwd); @@ -219,23 +237,40 @@ describe('objectui check — the unknown-type warning arm is untouched (objectui }); it('stays silent for a registered type', async () => { - writeFile('grid.json', '{"type":"object-grid","objectApiName":"account"}'); + writeFile( + 'grid.json', + '{"className":"p-0","type":"object-grid","objectApiName":"account"}' + ); + // Counter-probe: same marker, same admission path, a type nothing + // registers. Its warning is what makes `grid.json`'s silence a verdict + // instead of a file the gate never let through. + writeFile('probe.json', '{"className":"p-0","type":"totally-made-up-xyz"}'); await check(cwd); - expect(unknownTypeWarnings()).toEqual([]); + expect(unknownTypeWarnings()).toEqual([ + expect.stringContaining('Unknown schema type "totally-made-up-xyz" in probe.json'), + ]); expect(exitCodes).toEqual([]); }); it('does not run the type check on a file that failed to parse', async () => { // A parse failure short-circuits before the schema arm, exactly as the // thrown `JSON.parse` used to. - writeFile('broken-typed.json', '{ "type": "totally-made-up-xyz", }}'); + writeFile('broken-typed.json', '{ "className": "p-0", "type": "totally-made-up-xyz", }}'); + // Counter-probe: byte-for-byte the same document minus the stray brace. + // It warns, so the silence about `broken-typed.json` is attributable to + // the parse short-circuit — the only difference between the two files — + // and not to a marker the gate declined. + writeFile('parsed-typed.json', '{ "className": "p-0", "type": "totally-made-up-xyz" }'); await check(cwd); expect(parseErrorLines()).toHaveLength(1); - expect(unknownTypeWarnings()).toEqual([]); + expect(parseErrorLines()[0]).toContain('broken-typed.json'); + expect(unknownTypeWarnings()).toEqual([ + expect.stringContaining('Unknown schema type "totally-made-up-xyz" in parsed-typed.json'), + ]); expect(exitCodes).toEqual([1]); }); }); diff --git a/packages/cli/src/__tests__/check-known-types.test.ts b/packages/cli/src/__tests__/check-known-types.test.ts index e9e1b49bb..3d54f90cc 100644 --- a/packages/cli/src/__tests__/check-known-types.test.ts +++ b/packages/cli/src/__tests__/check-known-types.test.ts @@ -31,8 +31,29 @@ let cwd: string; let lines: string[]; let restoreLog: () => void; -function writeSchema(name: string, body: unknown): void { - writeFileSync(join(cwd, name), JSON.stringify(body)); +/** + * Every fixture carries `className` — a STRUCTURAL marker key — because + * objectui#5127 gated type judgement behind a positive ObjectUI marker: a bare + * `{"type": ...}` file is not judged at all now. Without a marker the warning + * assertions below would fail and — worse — the SILENCE assertion would keep + * passing while measuring nothing, which is the shape of a test that survives + * the deletion of the feature it covers. + * + * That is not assumed here, it is measured: dropping the injection from this + * helper turns the warning tests red and leaves the silence test GREEN, which + * is precisely why that test carries its own counter-probe below rather than + * trusting this comment. + * + * `className` is the least semantically loaded key in the marker set — it says + * nothing about a node's children, visibility or interaction state — so it + * perturbs no fixture's meaning. An earlier revision declared a `$schema` URL + * instead; the maintainer's 2026-08-20 ruling removed that arm, and a marker + * that names a way in the build no longer honours is a fixture that admits + * nothing. The gate itself is pinned separately, in + * `check-schema-marker.test.ts`; this file is about the derived key set. + */ +function writeSchema(name: string, body: Record): void { + writeFileSync(join(cwd, name), JSON.stringify({ className: 'p-0', ...body })); } /** Warnings only, with the ANSI colouring chalk may add stripped off. */ @@ -83,8 +104,16 @@ describe('objectui check — unknown schema types', () => { writeSchema('grid.json', { type: 'object-grid', objectApiName: 'account' }); writeSchema('ns-grid.json', { type: 'view:grid', objectApiName: 'account' }); writeSchema('gallery-ok.json', { type: 'object-gallery' }); + // Counter-probe. Written by the same helper, so it carries the same marker + // and travels the same admission path; its warning is what makes the three + // silences above VERDICTS rather than a judgement that never ran. Without + // it this assertion holds equally well when nothing is judged at all — + // measured, and the reason it is here (objectui#5127). + writeSchema('probe.json', { type: 'totally-made-up-xyz' }); await check(cwd); - expect(unknownTypeWarnings()).toEqual([]); + expect(unknownTypeWarnings()).toEqual([ + expect.stringContaining('Unknown schema type "totally-made-up-xyz" in probe.json'), + ]); }); it('still warns for a type nothing registers', async () => { @@ -99,6 +128,10 @@ describe('objectui check — unknown schema types', () => { // list would otherwise be free to become a breaking change by accident. writeSchema('bogus.json', { type: 'totally-made-up-xyz' }); await check(cwd); + // Both halves of this test's own sentence. Asserting only the exit + // neutrality would keep passing if the type were never REPORTED either, + // which is the state a lost marker puts this fixture in. + expect(unknownTypeWarnings()).toHaveLength(1); expect(lines.some((l) => l.includes('All checks passed'))).toBe(true); }); }); diff --git a/packages/cli/src/__tests__/check-schema-marker.test.ts b/packages/cli/src/__tests__/check-schema-marker.test.ts new file mode 100644 index 000000000..ab4ed07fb --- /dev/null +++ b/packages/cli/src/__tests__/check-schema-marker.test.ts @@ -0,0 +1,336 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `objectui check`'s positive schema marker (objectui#5127). + * + * A root `type` is not evidence that a file is a UI schema — `type` heads at + * least seven unrelated JSON vocabularies, and the most common of them is + * `package.json`'s `"type": "module"`. The command judged all of them, so the + * FIRST line a user saw running it in their own project was a warning about + * their own package manifest: 45 of the 46 warnings this repository produced + * were exactly that. + * + * Half of this file pins that those files stopped being judged. The OTHER half + * pins that real schemas are still judged and still warn — a suite that only + * proved things went quiet would pass a change that deleted the feature, which + * is the failure mode this gate is one step away from by construction. + * + * Fixtures live under `os.tmpdir()`, never in the repo tree: `check()` globs + * every JSON file under the directory it is handed, so a fixture committed + * inside this workspace would be scanned by every other run of the command as + * well — including the repo's own `pnpm check`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { check } from '../commands/check.js'; + +let cwd: string; +let lines: string[]; +let exitCodes: number[]; +let restoreLog: () => void; + +function writeSchema(name: string, body: unknown): void { + writeFileSync(join(cwd, name), JSON.stringify(body)); +} + +/** Write a fixture verbatim — for JSONC sources, not `JSON.stringify` output. */ +function writeRaw(name: string, body: string): void { + writeFileSync(join(cwd, name), body); +} + +/** + * The CSI sequences chalk may add. The escape byte is built with + * `String.fromCharCode` rather than spelled into the source, so this file holds + * no raw control character and no escape a tooling pass could materialise into + * one (objectui AGENTS.md byte discipline). + */ +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +function plainLines(): string[] { + return lines.map((l) => l.replace(ANSI, '')); +} + +function unknownTypeWarnings(): string[] { + return plainLines().filter((l) => l.includes('Unknown schema type')); +} + +/** The advisory line `check` prints under the skipped-file count. */ +function hintLine(): string { + const line = plainLines().find((l) => l.includes('A file is checked when')); + if (line === undefined) throw new Error('no hint line was printed'); + return line; +} + +/** + * The marker keys the hint advertises to the reader, read back out of the + * printed sentence rather than restated here — so a test can check that what + * the command SAYS and what it DOES are the same set. + */ +function advertisedKeys(): string[] { + const match = /structural key: (.+)\.$/.exec(hintLine()); + if (!match) throw new Error(`hint present but unparseable: ${hintLine()}`); + return match[1].split(', '); +} + +/** The count `check` reports for files it declined to judge, or 0 if silent. */ +function skippedCount(): number { + const line = plainLines().find((l) => l.startsWith('Skipped ')); + if (!line) return 0; + const match = /^Skipped (\d+) file/.exec(line); + if (!match) throw new Error(`skip line present but unparseable: ${line}`); + return Number(match[1]); +} + +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'objectui-check-marker-')); + lines = []; + exitCodes = []; + const original = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + // `check()` calls `process.exit(1)` itself; record the call instead of + // letting it tear the Vitest worker down. + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCodes.push(code ?? 0); + return undefined as never; + }) as never); + restoreLog = () => { + console.log = original; + exitSpy.mockRestore(); + }; +}); + +afterEach(() => { + restoreLog(); + rmSync(cwd, { recursive: true, force: true }); +}); + +describe('objectui check — foreign root-`type` vocabularies are never judged', () => { + it('says nothing about `package.json`\'s "type": "module" — the card\'s headline symptom', async () => { + writeSchema('package.json', { name: 'x', version: '1.0.0', type: 'module' }); + await check(cwd); + expect(unknownTypeWarnings()).toEqual([]); + }); + + it('says nothing about a JSON Schema document', async () => { + // Two documents, and the SECOND one is the load-bearing half. `object` is + // itself a registered component key, so a JSON Schema whose root type is + // `object` was silent before this change too — it would pass here for a + // reason that has nothing to do with the marker. `array` is not registered, + // so the array document is one that genuinely warned before. + // + // Note the array document carries `items`, which is why `items` is absent + // from the structural-key set even though ObjectUI nodes use it: a marker + // key shared with JSON Schema re-admits exactly the files this gate exists + // to keep out. + writeSchema('thing.schema.json', { + $schema: 'http://json-schema.org/draft-07/schema#', + title: 'Thing', + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }); + writeSchema('tags.schema.json', { + $schema: 'http://json-schema.org/draft-07/schema#', + title: 'Tags', + type: 'array', + items: { type: 'string' }, + }); + await check(cwd); + expect(unknownTypeWarnings()).toEqual([]); + expect(skippedCount()).toBe(2); + }); + + it('says nothing about a deployment resource descriptor', async () => { + // The third big root-`type` vocabulary after package manifests and JSON + // Schema: infrastructure resources. `properties` is deliberately not a + // marker key, so this file is not admitted by the structural arm. + writeSchema('bucket.json', { + type: 'aws:s3/bucket:Bucket', + properties: { acl: 'private' }, + }); + await check(cwd); + expect(unknownTypeWarnings()).toEqual([]); + expect(skippedCount()).toBe(1); + }); + + it('never even sees a dot-prefixed config — those are outside the scan', async () => { + // `.eslintrc.json` is the obvious fixture for this suite and it would be a + // PHANTOM: `globSync` does not match dot-prefixed names without `dot: true`, + // so the file is not scanned at all and an assertion of silence over it + // holds whatever the marker does. Measured, and it is why this test asserts + // the scope rather than the marker. + // + // The scanned sibling is the counter-probe: without it, "no warnings" here + // would be indistinguishable from a scan that read nothing. + writeSchema('.eslintrc.json', { root: true, type: 'totally-made-up-xyz', rules: {} }); + writeSchema('page.json', { type: 'totally-made-up-xyz', children: [] }); + await check(cwd); + // Exactly one warning, from the scanned file — the dotfile contributed + // neither a warning nor a skip. + expect(unknownTypeWarnings()).toEqual([ + expect.stringContaining('Unknown schema type "totally-made-up-xyz" in page.json'), + ]); + expect(skippedCount()).toBe(0); + }); + + it('says nothing about a tsconfig-derived file, and does not count it as skipped', async () => { + // A `tsconfig` carries no root `type` at all, so it was never ELIGIBLE for + // judgement and must not inflate the skipped count either — that count + // reports lost coverage, and a file that was never judged is not lost + // coverage. It is written as real JSONC to keep the objectui#5237 parse + // arm exercised alongside this one. + writeRaw( + 'tsconfig.build.json', + '{\n // extends the base config\n "extends": "./tsconfig.json",\n "compilerOptions": { "outDir": "dist" },\n}\n' + ); + await check(cwd); + expect(unknownTypeWarnings()).toEqual([]); + expect(skippedCount()).toBe(0); + expect(exitCodes).toEqual([]); + }); + + it('does not judge a foreign file even when its `type` happens to name a real component', async () => { + // `form` IS a registered component key, so this file was silent before the + // marker too — but for the wrong reason. Pinning it keeps the gate honest: + // the file is out of scope, not accidentally acceptable. + writeSchema('package.json', { name: 'x', type: 'form' }); + await check(cwd); + expect(unknownTypeWarnings()).toEqual([]); + expect(skippedCount()).toBe(1); + }); +}); + +describe('objectui check — a real schema is still judged by the structural arm', () => { + it('judges a file carrying a structural key, with no `$schema` at all', async () => { + writeSchema('page.json', { + type: 'totally-made-up-xyz', + children: [{ type: 'text', body: 'hi' }], + }); + await check(cwd); + expect(unknownTypeWarnings()).toHaveLength(1); + expect(unknownTypeWarnings()[0]).toContain('"totally-made-up-xyz"'); + }); + + it('stays silent for a REGISTERED type on a judged file — the marker admits, it does not warn', async () => { + writeSchema('grid.json', { type: 'object-grid', className: 'h-full' }); + writeSchema('ns-grid.json', { type: 'view:grid', body: [] }); + await check(cwd); + expect(unknownTypeWarnings()).toEqual([]); + expect(skippedCount()).toBe(0); + }); + + it('does not admit a file on `$schema` alone — no URL is a marker', async () => { + // ⛔ NEGATIVE fixtures. ObjectUI publishes NO `$schema` URL, and none is + // being minted: the maintainer ruled against it (2026-08-20, verbatim + // 「C」), superseding the `$schema` half of the 2026-08-19 ruling. An + // earlier revision of this command admitted any file whose `$schema` host + // was `objectui.org`; these two files pin that that arm is GONE, so + // re-adding it turns this test red rather than passing unnoticed. + // + // The paths below are arbitrary on purpose — no spelling of an ObjectUI + // schema URL means anything to this command, which is the whole point. + writeSchema('declared.json', { + $schema: 'https://objectui.org/some/path.json', + type: 'totally-made-up-xyz', + }); + writeSchema('relative.json', { $schema: './objectui-schema.json', type: 'totally-made-up-xyz' }); + // Counter-probe: the same unregistered type, admitted structurally. Its + // warning proves the two silences above are the gate declining these + // files, not a run that judged nothing. + writeSchema('probe.json', { type: 'totally-made-up-xyz', children: [] }); + await check(cwd); + expect(unknownTypeWarnings()).toEqual([ + expect.stringContaining('Unknown schema type "totally-made-up-xyz" in probe.json'), + ]); + expect(skippedCount()).toBe(2); + }); +}); + +describe('objectui check — the narrowed judgement surface is never silent (option D)', () => { + it('reports how many eligible files went unjudged, and how to opt one in', async () => { + writeSchema('package.json', { name: 'x', type: 'module' }); + // The two leaves are the majority shape of the real in-repo corpus: a + // single node with no structural key, which is precisely the coverage this + // interim gives up and which this count keeps visible. + writeSchema('leaf-1.json', { type: 'button', label: 'Send Email', icon: 'mail' }); + writeSchema('leaf-2.json', { type: 'badge', variant: 'default' }); + await check(cwd); + // Two leaves plus the manifest: three files had a root `type` string and + // no marker. + expect(skippedCount()).toBe(3); + const hint = hintLine(); + expect(hint).toContain('children'); + expect(hint).toContain('className'); + // ⛔ The hint must not advise declaring a `$schema` URL: no such URL exists + // and no arm reads one (maintainer ruling 2026-08-20, verbatim 「C」). A + // hint naming a way in the build does not honour is worse than no hint — + // the reader follows it, nothing changes, and the command reads as broken + // rather than narrow. This is the "comment claims != code enforces" shape, + // pinned so the sentence cannot outlive the code it describes. + expect(hint).not.toContain('$schema'); + expect(hint).not.toContain('http'); + }); + + it('names only markers the gate actually honours — every key in the hint admits a file', async () => { + // The mechanical version of the assertion above. The hint is READ back and + // each key it advertises is fed to the command on its own file: if the + // sentence ever names a key the gate does not honour, that file is skipped + // and this test goes red. Nothing here restates the key set by hand, so it + // cannot drift from the gate the way a duplicated list would. + writeSchema('package.json', { name: 'x', type: 'module' }); + await check(cwd); + const keys = advertisedKeys(); + // Sanity: the hint advertises a real set, not an empty one. + expect(keys.length).toBeGreaterThan(0); + + // The manifest has served its purpose (it made the hint print); leave it in + // place and it would keep counting as a skipped file in the second run. + rmSync(join(cwd, 'package.json')); + lines = []; + for (const [i, key] of keys.entries()) { + writeSchema(`node-${i}.json`, { type: 'totally-made-up-xyz', [key]: 'x' }); + } + await check(cwd); + // Every advertised key admitted its file, so every file was judged and + // warned; none was skipped. + expect(unknownTypeWarnings()).toHaveLength(keys.length); + expect(skippedCount()).toBe(0); + }); + + it('says nothing when every eligible file carried a marker', async () => { + writeSchema('page.json', { type: 'card', children: [] }); + await check(cwd); + expect(skippedCount()).toBe(0); + expect(plainLines().some((l) => l.startsWith('Skipped '))).toBe(false); + }); + + it('is a report, not a failure — the run still passes', async () => { + writeSchema('package.json', { name: 'x', type: 'module' }); + await check(cwd); + expect(skippedCount()).toBe(1); + expect(plainLines().some((l) => l.includes('All checks passed'))).toBe(true); + expect(exitCodes).toEqual([]); + }); +}); + +describe('objectui check — the marker gate did not disturb the parse arm', () => { + it('still counts malformed JSON as an error and still exits 1', async () => { + writeRaw('broken.json', '{ "type": "card", "children": [ }'); + await check(cwd); + expect(plainLines().some((l) => l.includes('Invalid JSON in broken.json'))).toBe(true); + expect(plainLines().some((l) => l.includes('Found 1 errors'))).toBe(true); + expect(exitCodes).toEqual([1]); + }); +}); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index ba2f12fc6..c5fe4176e 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -14,6 +14,102 @@ import { parse as parseJsonc, printParseErrorCode, type ParseError } from 'jsonc import { isKnownSchemaType } from '../utils/known-schema-types.js'; +/** + * Root keys that positively identify a file as an ObjectUI schema node. + * + * Every entry is a property DECLARED on `BaseSchema` + * (`packages/types/src/base.ts`) — this set is read out of the protocol's own + * node contract, not invented here, and it is closed: it grows only when + * `BaseSchema` grows. + * + * `BaseSchema`'s remaining properties are deliberately absent because their + * NAMES are generic enough to head foreign root vocabularies, and a marker + * that matches a foreign file re-creates the exact defect this gate exists to + * remove. Measured over this repository (objectui#5127): `name` appears at the + * root of 45 of the 46 foreign judged files and `description` at 44 — both are + * `package.json` keys — so admitting `BaseSchema` wholesale takes the foreign + * retention from 0 files to all 46. `id`, `label`, `data` and `type` itself are + * held out on the same reasoning. + */ +const OBJECTUI_STRUCTURAL_KEYS: readonly string[] = [ + // Composition — the `children`/`body`-class keys the ruling names. + 'body', + 'children', + // Presentation. + 'className', + 'style', + 'placeholder', + // Conditional rendering and interaction state. + 'visible', + 'visibleWhen', + 'visibleOn', + 'hidden', + 'hiddenOn', + 'disabled', + 'disabledOn', + // Test and accessibility handles. + 'testId', + 'ariaLabel', +]; + +/** + * ⛔ There is deliberately NO `$schema` arm, and no ObjectUI `$schema` URL + * exists to declare. + * + * An earlier revision of this file admitted a file whose root `$schema` had an + * `objectui.org` host, completed by a canonical URL proposed for the + * maintainer to confirm. The maintainer ruled against minting that identifier + * (2026-08-20, objectui#5127, verbatim 「C」), superseding the `$schema` half of + * the 2026-08-19 ruling. The structural arm below is unaffected and stands. + * + * - A `$schema` URL is a permanent public identifier that lives in USERS' + * files, which no sweep can ever reach. Minting the address before the + * document it names exists is backwards. + * - The only consumer that needs a URL rather than any agreed marker is an + * editor resolving `$schema` over HTTP. An agent authoring schemas instead + * needs a contract it can read BEFORE writing — a file, and `node_modules` + * is right there — and a good error AFTER writing, which is this command, + * already version-correct from the installed packages. + * - The value would be untyped, unchecked and host-matched, so an agent that + * mis-remembered the path would be silently accepted: a version-fossil + * generator, in files we cannot fix. + * - Zero files declared it, in this repository or anywhere else, so the arm + * matched nothing. Dropping it gives up no coverage that was being + * delivered. + * + * Because that matching was host-based rather than literal, the arm can be + * added later without invalidating a single file — most soundly once + * `@object-ui/types` ships a generated JSON Schema, which is filed separately. + */ + +/** + * Does this parsed file positively read as an ObjectUI schema (objectui#5127)? + * + * `type` carries at least seven unrelated vocabularies in JSON — the most + * common of them being `package.json`'s `"type": "module"` — so the presence + * of a root `type` is not evidence that a file is a UI schema. Before this + * gate, `objectui check` judged every root `type` against the component-key + * universe and the FIRST line a user saw in their own project was a warning + * about their own `package.json`: 45 of the 46 warnings this repository + * produced were exactly that. + * + * The fix is a positive marker, not a consumer-side skip list: a file is + * judged when it is structurally recognisable as an ObjectUI node. A list of + * filenames to exclude was + * considered and rejected — it is a second hand-maintained list of the shape + * objectui#5115 had just finished deleting, and it can only ever enumerate the + * foreign vocabularies someone already thought of. + * + * ⚠️ The structural arm is a TRANSITIONAL fallback, not the contract. It is + * drawn for precision over recall on purpose: a foreign file wrongly judged is + * this card's defect reappearing in a user's project, while a real schema + * wrongly skipped is a coverage debt that the corpus migration repays and that + * the skipped-file count below keeps visible in the meantime. + */ +function isObjectUiSchemaFile(content: Record): boolean { + return OBJECTUI_STRUCTURAL_KEYS.some((key) => key in content); +} + /** * Render a `jsonc-parser` error the way `JSON.parse` renders its own: a reason, * then where it happened. The parser reports a byte offset; the line/column is @@ -44,6 +140,12 @@ export async function check(cwd: string = process.cwd()) { console.log(`Analyzing ${files.length} files...`); let errors = 0; + // Files that a root `type` string made ELIGIBLE for type judgement and that + // no ObjectUI marker admitted. Reported below so the narrowed judgement + // surface is never silent (objectui#5127) — a check that quietly stops + // checking is worse than one that warns too loudly, because nothing prompts + // a re-read. + let skipped = 0; for (const file of files) { try { @@ -82,12 +184,24 @@ export async function check(cwd: string = process.cwd()) { // Schema validation: check for ObjectUI schema patterns if (content && typeof content === 'object' && content.type) { - // The known-type universe is DERIVED from the repository's - // registration calls (see `packages/cli/src/utils/known-schema-types.ts` - // and the script that writes it), not typed by hand. The array that - // used to sit here had drifted both ways at once — objectui#5115. - if (typeof content.type === 'string' && !isKnownSchemaType(content.type)) { - console.log(chalk.yellow(`⚠️ Unknown schema type "${content.type}" in ${file}`)); + if (typeof content.type === 'string') { + // The marker gate (objectui#5127). Only a file that positively + // reads as an ObjectUI schema enters type judgement; every other + // root-`type` vocabulary — `package.json`'s `"module"`, + // JSON Schema's `"object"`, and the rest — is simply not judged. + // + // Note this arm is reached for `.json` only, as the parse above + // is. The glob also matches `.yaml`/`.yml`, and those files are + // read by neither arm, before this change or after it. + if (!isObjectUiSchemaFile(content as Record)) { + skipped++; + } else if (!isKnownSchemaType(content.type)) { + // The known-type universe is DERIVED from the repository's + // registration calls (see `packages/cli/src/utils/known-schema-types.ts` + // and the script that writes it), not typed by hand. The array that + // used to sit here had drifted both ways at once — objectui#5115. + console.log(chalk.yellow(`⚠️ Unknown schema type "${content.type}" in ${file}`)); + } } } } @@ -96,6 +210,26 @@ export async function check(cwd: string = process.cwd()) { errors++; } } + + if (skipped > 0) { + console.log( + chalk.dim( + `Skipped ${skipped} file${skipped === 1 ? '' : 's'} with a root "type" and no ObjectUI schema marker.` + ) + ); + // The hint names the marker keys by rendering the gate's OWN array, never + // a sentence written alongside it. A hint that describes a way in that the + // build does not honour is worse than no hint: the reader follows it, + // nothing changes, and the command looks broken rather than narrow. The + // previous revision of this line advised declaring a `$schema` URL — an + // arm the 2026-08-20 ruling removed — which is exactly that failure had it + // outlived the code it described. + console.log( + chalk.dim( + ` A file is checked when its root carries an ObjectUI structural key: ${OBJECTUI_STRUCTURAL_KEYS.join(', ')}.` + ) + ); + } if (errors === 0) { console.log(chalk.green('✓ All checks passed'));