From 1291491a361db408df9be1a837b1009cf26f730d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 03:50:37 +0000 Subject: [PATCH] feat(lint): extract the canonical-envelope page audit into @objectstack/lint; gate cloud-connection's pages The three-door detector from platform-objects' canonical-expression-envelopes gate moves to @objectstack/lint beside page-walk.ts as auditPageExpressionEnvelopes / renderBareExpressionFindings, so every package shipping raw-literal Page exports can run the same gate. platform-objects' gate now consumes the shared export (population scan, preconditions, verdict and shipped-page downgrade control unchanged); cloud-connection gains a thin gate over its two shipped pages, with @objectstack/lint devDependency and the anchored vitest source alias. MarketplaceInstalledPage is declared : Page (type-level only) so export-shape discovery sees it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2 --- .changeset/envelope-audit-shared-home.md | 6 + packages/cloud-connection/package.json | 1 + .../canonical-expression-envelopes.test.ts | 258 ++++++++++ .../cloud-connection/src/marketplace-ui.ts | 12 +- packages/cloud-connection/vitest.config.ts | 40 ++ packages/lint/src/index.ts | 22 + packages/lint/src/page-envelope-audit.test.ts | 177 +++++++ packages/lint/src/page-envelope-audit.ts | 343 +++++++++++++ .../canonical-expression-envelopes.test.ts | 468 ++---------------- pnpm-lock.yaml | 3 + 10 files changed, 901 insertions(+), 429 deletions(-) create mode 100644 .changeset/envelope-audit-shared-home.md create mode 100644 packages/cloud-connection/src/canonical-expression-envelopes.test.ts create mode 100644 packages/cloud-connection/vitest.config.ts create mode 100644 packages/lint/src/page-envelope-audit.test.ts create mode 100644 packages/lint/src/page-envelope-audit.ts diff --git a/.changeset/envelope-audit-shared-home.md b/.changeset/envelope-audit-shared-home.md new file mode 100644 index 0000000000..573787c85a --- /dev/null +++ b/.changeset/envelope-audit-shared-home.md @@ -0,0 +1,6 @@ +--- +'@objectstack/lint': minor +'@objectstack/cloud-connection': patch +--- + +The canonical-expression-envelope detector for raw-literal `Page` exports gets a shared home in `@objectstack/lint` (#11480). New public API beside `walkPageComponents`: `auditPageExpressionEnvelopes(page, label)` runs the three parse doors (`PageSchema` / `PageComponentSchema` / `ComponentPropsMap`) over one authored page and reports bare-expression findings plus every door's precondition failures; `renderBareExpressionFindings(findings)` renders the actionable red; types `BareExpressionFinding`, `PageEnvelopeAudit`, `EnvelopeAuditDoor`. The detector previously lived package-local to `@objectstack/platform-objects`' gate, which could not reach raw-literal pages shipped by other packages. `@objectstack/cloud-connection`'s two shipped pages are now covered by the same gate, and `MarketplaceInstalledPage` is declared `: Page` (type-level only; no runtime change) so export-shape page discovery sees it. diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index 3307bee7ca..0e69b76768 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -24,6 +24,7 @@ "@objectstack/types": "workspace:*" }, "devDependencies": { + "@objectstack/lint": "workspace:*", "@types/node": "^26.2.0", "typescript": "^6.0.3", "vitest": "^4.1.10" diff --git a/packages/cloud-connection/src/canonical-expression-envelopes.test.ts b/packages/cloud-connection/src/canonical-expression-envelopes.test.ts new file mode 100644 index 0000000000..7ba94613bf --- /dev/null +++ b/packages/cloud-connection/src/canonical-expression-envelopes.test.ts @@ -0,0 +1,258 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Gate — every `Page` this package ships must serve the CANONICAL + * `{ dialect, source }` envelope at every `ExpressionInputSchema` position + * (#11480, extending #11255's platform-objects gate to this package). + * + * Both pages here are raw typed object literals reaching the kernel through + * this plugin's own manifest bundles (`CLOUD_CONNECTION_UI_BUNDLE`, + * `MARKETPLACE_INSTALLED_UI_BUNDLE`) — the same wire path as + * `platform-objects`' pages, in files no `*.page.ts` sweep ever looked at. + * They author ZERO expression keys today, which is exactly why the gate is + * worth having: the hazard is the NEXT predicate added to one of them, which + * would ship bare with every authoring-time signal green. + * + * The detector lives in `@objectstack/lint` (`page-envelope-audit.ts` — its + * header carries the hazard and the three-door design; its own test file + * carries the negative controls). What this file owns is this package's + * POPULATION: the export-shape scan over `src/`, the per-page door + * preconditions, the verdict, and a downgrade control proving the detector + * reaches these real exports. + * + * ## The two exempted component types + * + * `cloud-connection:panel` and `marketplace:installed-list` are + * console-registered widgets with no `ComponentPropsMap` row, so door 3 has + * no schema to read their `properties` with. The exemption is asserted + * EXACTLY (a new unmapped type reds), and it is valid only while those + * components author an EMPTY props bag — nothing authored is nothing to + * serve bare. The moment either widget grows a real authored prop, the + * emptiness assert reds and forces the decision: give the type a + * `ComponentPropsMap` row, or widen the exemption knowingly. + */ + +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { Page } from '@objectstack/spec/ui'; +import { + auditPageExpressionEnvelopes, + renderBareExpressionFindings, + walkPageComponents, +} from '@objectstack/lint'; +import { CloudConnectionSettingsPage } from './cloud-connection-ui.js'; +import { MarketplaceInstalledPage } from './marketplace-ui.js'; + +type AnyRec = Record; + +/** This file lives in `src/`, so the scan root IS the package's `src/`. */ +const HERE = dirname(fileURLToPath(import.meta.url)); + +// ─────────────────────────────────────────────────────────────────────────── +// The population this gate covers +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Every page this package ships, audited by export name — with the unmapped + * component types each page is EXPECTED to report (the exemptions above). + */ +const AUDITED_PAGES: { exportName: string; page: Page; exemptUnmappedTypes: string[] }[] = [ + { + exportName: 'CloudConnectionSettingsPage', + page: CloudConnectionSettingsPage, + exemptUnmappedTypes: ['cloud-connection:panel'], + }, + { + exportName: 'MarketplaceInstalledPage', + page: MarketplaceInstalledPage, + exemptUnmappedTypes: ['marketplace:installed-list'], + }, +]; + +function pageLabel(exportName: string, page: Page): string { + const name = typeof (page as AnyRec).name === 'string' ? (page as AnyRec).name : '(unnamed)'; + return `${exportName} (${String(name)})`; +} + +function tsFilesUnder(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist') continue; + tsFilesUnder(full, out); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) { + out.push(full); + } + } + return out; +} + +/** Strip comments so a `: Page =` inside prose is not read as a declaration. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, ''); +} + +/** + * Every `export const X: Page = …` declared anywhere in this package's `src/`. + * + * Discovery is by EXPORT SHAPE, never by filename — the sweep that first + * recorded this defect class looked at `*.page.ts` and therefore missed this + * package's pages entirely (they live in `*-ui.ts` files). Scanning source + * text rather than a barrel is what makes "a page nobody covered" visible. + */ +function declaredPageExports(): { name: string; file: string }[] { + const out: { name: string; file: string }[] = []; + for (const file of tsFilesUnder(HERE)) { + const source = stripComments(readFileSync(file, 'utf8')); + for (const match of source.matchAll(/export\s+const\s+(\w+)\s*:\s*Page\s*=/g)) { + out.push({ name: match[1]!, file: file.slice(HERE.length + 1) }); + } + } + return out.sort((a, b) => a.name.localeCompare(b.name)); +} + +const AUDITS = AUDITED_PAGES.map(({ exportName, page, exemptUnmappedTypes }) => ({ + exportName, + page, + exemptUnmappedTypes, + audit: auditPageExpressionEnvelopes(page, pageLabel(exportName, page)), +})); + +// ─────────────────────────────────────────────────────────────────────────── +// The gate +// ─────────────────────────────────────────────────────────────────────────── + +describe('cloud-connection Page exports serve canonical expression envelopes', () => { + it('covers every `Page` declared in this package — and audits nothing undeclared', () => { + const declared = declaredPageExports(); + const audited = new Set(AUDITED_PAGES.map(p => p.exportName)); + const uncovered = declared.filter(d => !audited.has(d.name)); + expect( + uncovered.map(d => `${d.name} (${d.file})`).join('\n'), + 'a raw-literal `Page` in this package is not audited by this gate. Add it to ' + + 'AUDITED_PAGES above (or, if it is deliberately unshipped, say so here).', + ).toBe(''); + + // Both directions: a page audited here but invisible to the export-shape + // scan means its declaration lost the `: Page` annotation — the exact way + // MarketplaceInstalledPage shipped un-discoverable before #11480. + const declaredNames = new Set(declared.map(d => d.name)); + const undeclared = AUDITED_PAGES.filter(p => !declaredNames.has(p.exportName)); + expect( + undeclared.map(p => p.exportName).join('\n'), + 'this page is audited but not discovered by the `export const X: Page =` scan — ' + + 'restore the `: Page` annotation on its declaration so the NEXT page authored ' + + 'beside it is discoverable too.', + ).toBe(''); + + // Population floor: the gate is worthless if it silently reads nothing. + expect(AUDITED_PAGES.length).toBeGreaterThanOrEqual(2); + expect(declared.length).toBeGreaterThanOrEqual(2); + }); + + it.each(AUDITS)('$exportName parses through PageSchema (door 1 precondition)', ({ audit }) => { + expect( + audit.pageParseError ?? '', + 'door 1 cannot run: this page does not parse, so every schema-typed expression ' + + 'position on it is unread by this gate.', + ).toBe(''); + }); + + it.each(AUDITS)('$exportName: every component parses through PageComponentSchema (door 2 precondition)', ({ audit }) => { + expect( + audit.componentParseErrors.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'), + 'door 2 cannot run for these components: they do not parse, so their expression ' + + 'positions are unread by this gate.', + ).toBe(''); + expect(audit.componentCount).toBeGreaterThan(0); + }); + + it.each(AUDITS)('$exportName: unmapped component types are EXACTLY the recorded exemptions (door 3 precondition)', ({ audit, exemptUnmappedTypes }) => { + // See the module header for why these two types are exempt. Anything else + // unmapped is a new door-3 blind spot: declare the props schema in + // `ComponentPropsMap`, or record the exemption here with the reason. + expect(audit.unmappedTypes.map(e => e.type).sort()).toEqual([...exemptUnmappedTypes].sort()); + }); + + it.each(AUDITS)('$exportName: every exempted component authors an EMPTY props bag', ({ page, exemptUnmappedTypes }) => { + // The exemption above is only sound while there is nothing authored for + // door 3 to miss. A real key landing in one of these bags must force a + // decision (props schema row, or a conscious wider exemption) — not ride + // through a standing exemption silently. + const offenders = walkPageComponents(page as AnyRec, '') + .filter(w => typeof w.component.type === 'string' && exemptUnmappedTypes.includes(w.component.type)) + .filter(w => { + const props = w.component.properties; + return !!props && typeof props === 'object' && Object.keys(props).length > 0; + }) + .map(w => `${w.path} [${String(w.component.type)}]`); + expect(offenders.join('\n')).toBe(''); + }); + + it.each(AUDITS)('$exportName: every authored `properties` bag parses against its props schema (door 3 precondition)', ({ audit }) => { + expect( + audit.unreadableProps.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'), + 'door 3 cannot run for these components: their authored `properties` are refused by ' + + 'the declared props schema, so a props-level expression key there is unread by ' + + 'this gate.', + ).toBe(''); + }); + + it.each(AUDITS)('$exportName authors NO bare expression string', ({ audit }) => { + expect(renderBareExpressionFindings(audit.findings)).toBe(''); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Downgrade control — the imported detector reaches this package's REAL pages +// ─────────────────────────────────────────────────────────────────────────── + +const BARE = 'has(record.status) && record.status == "bound"'; + +describe('downgrade control — a shipped page, bare predicate injected', () => { + it('flags CloudConnectionSettingsPage the moment a bare predicate lands on its panel', () => { + // Deep-cloned — the export itself is untouched (the pristine re-audit + // below proves it). The injected position is the panel component's + // `visibleWhen`, i.e. the exact next-predicate the card names as the + // hazard for this page. + const source = JSON.parse(JSON.stringify(CloudConnectionSettingsPage)) as AnyRec; + const regions = source.regions as AnyRec[]; + const panel = (regions[1]!.components as AnyRec[])[0]!; + expect(panel.type).toBe('cloud-connection:panel'); + panel.visibleWhen = BARE; + + const audit = auditPageExpressionEnvelopes(source, pageLabel('CloudConnectionSettingsPage', source as unknown as Page)); + expect(audit.findings.map(f => f.path)).toEqual(['regions[1].components[0].visibleWhen']); + const rendered = renderBareExpressionFindings(audit.findings); + expect(rendered).toContain('cloud_connection_settings'); + expect(rendered).toContain('regions[1].components[0].visibleWhen'); + expect(rendered).toContain('authored BARE'); + + const pristine = auditPageExpressionEnvelopes( + CloudConnectionSettingsPage, + pageLabel('CloudConnectionSettingsPage', CloudConnectionSettingsPage), + ); + expect(renderBareExpressionFindings(pristine.findings)).toBe(''); + }); + + it('flags MarketplaceInstalledPage the same way — the page the `: Page` scan used to miss', () => { + const source = JSON.parse(JSON.stringify(MarketplaceInstalledPage)) as AnyRec; + const regions = source.regions as AnyRec[]; + const list = (regions[1]!.components as AnyRec[])[0]!; + expect(list.type).toBe('marketplace:installed-list'); + list.visibleWhen = BARE; + + const audit = auditPageExpressionEnvelopes(source, pageLabel('MarketplaceInstalledPage', source as unknown as Page)); + expect(audit.findings.map(f => f.path)).toEqual(['regions[1].components[0].visibleWhen']); + const rendered = renderBareExpressionFindings(audit.findings); + expect(rendered).toContain('marketplace_installed'); + + const pristine = auditPageExpressionEnvelopes( + MarketplaceInstalledPage, + pageLabel('MarketplaceInstalledPage', MarketplaceInstalledPage), + ); + expect(renderBareExpressionFindings(pristine.findings)).toBe(''); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-ui.ts b/packages/cloud-connection/src/marketplace-ui.ts index 36049b2804..907ad25b15 100644 --- a/packages/cloud-connection/src/marketplace-ui.ts +++ b/packages/cloud-connection/src/marketplace-ui.ts @@ -17,6 +17,8 @@ * stages (Installed Apps first). */ +import type { Page } from '@objectstack/spec/ui'; + /** "Browse Marketplace" — owned by the browse capability (the proxy). */ export const MARKETPLACE_BROWSE_UI_BUNDLE = { id: 'com.objectstack.cloud-connection.marketplace-browse-ui', @@ -40,8 +42,14 @@ export const MARKETPLACE_BROWSE_UI_BUNDLE = { /** "Installed Apps" — owned by the local-install capability (ADR-0009 P2a: * the page itself is now metadata; the console provides only the - * `marketplace:installed-list` widget). */ -export const MarketplaceInstalledPage = { + * `marketplace:installed-list` widget). + * + * Declared `: Page` (#11480) — this is a raw-literal page served through the + * bundle below, and `export const X: Page =` is the export shape the + * canonical-envelope gate's population scan discovers pages by. Un-annotated + * it shipped invisibly to that scan (measured while wiring this package's + * `canonical-expression-envelopes.test.ts`). */ +export const MarketplaceInstalledPage: Page = { name: 'marketplace_installed', label: 'Installed Apps', type: 'app' as const, diff --git a/packages/cloud-connection/vitest.config.ts b/packages/cloud-connection/vitest.config.ts new file mode 100644 index 0000000000..72243c2945 --- /dev/null +++ b/packages/cloud-connection/vitest.config.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + resolve: { + // One entry, for `canonical-expression-envelopes.test.ts` (#11480) — the + // only suite here that imports `@objectstack/lint` as a VALUE. It runs the + // shared canonical-envelope detector (`auditPageExpressionEnvelopes`) over + // this package's own `Page` exports. + // + // Unaliased, that specifier resolves through `exports` to `lint/dist` — a + // BUILD ARTIFACT — which would make the gate a verdict about build state + // rather than about the source next to it (`pnpm check:test-source-alias`, + // #7668/#7778). The loud failure (missing export) is the mild half; a dist + // merely BEHIND lets the gate run GREEN against the detector's old + // behaviour with nothing in the output saying so — and this suite's whole + // purpose is to run the CURRENT detector over the CURRENT pages. + // + // Array form with an anchored pattern, deliberately, and here that is + // load-bearing rather than stylistic: `@objectstack/lint` exports a second + // subpath (`./runtime`), and the object form matches by PREFIX, so a bare + // `@objectstack/lint` key with a FILE replacement would also swallow + // `@objectstack/lint/runtime` and resolve it to `…/lint/src/index.ts/runtime` + // — `ENOTDIR`, at run time, in a config that reads as correct. Same shape + // as `packages/platform-objects`'s config (the reference consumer of this + // detector), `packages/rest`'s (#7955) and `service-storage`'s (#7778). + alias: [ + { + find: /^@objectstack\/lint$/, + replacement: path.resolve(__dirname, '../lint/src/index.ts'), + }, + ], + }, + // No `test` block: this package had no vitest config until now, so its suite + // ran on vitest's defaults. Leaving discovery untouched keeps this file's + // only effect the alias above — narrowing `include` here would silently drop + // the rest of the package's suite while this gate stayed green. +}); diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 0ce837204b..f1631a4775 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -700,3 +700,25 @@ export type { // package is exactly what the module exists to prevent (#5405). export { walkPageComponents, isSourceAuthoredPage } from './page-walk.js'; export type { WalkedComponent } from './page-walk.js'; + +// The canonical-expression-envelope audit for raw-literal `Page` exports +// (#11255 → #11480): a page authored as a typed object literal is never +// PARSED, so every `ExpressionInputSchema` position on it reaches the wire +// bare and the console silently routes it to its legacy evaluator. Built on +// `walkPageComponents` above and exported for the same reason that walk is: +// the detector's first home was package-local, which left every OTHER +// published package's pages unreachable, and copying it per package is the +// documented dead-rule failure mode. Each owning package runs a thin test +// over its own `Page` exports; the detector's own behaviour is tested once, +// in `page-envelope-audit.test.ts`. (`collectBare`, the single-door +// primitive, is deliberately NOT re-exported — consumers always want the +// three-door union.) +export { + auditPageExpressionEnvelopes, + renderBareExpressionFindings, +} from './page-envelope-audit.js'; +export type { + BareExpressionFinding, + EnvelopeAuditDoor, + PageEnvelopeAudit, +} from './page-envelope-audit.js'; diff --git a/packages/lint/src/page-envelope-audit.test.ts b/packages/lint/src/page-envelope-audit.test.ts new file mode 100644 index 0000000000..1bced8dc28 --- /dev/null +++ b/packages/lint/src/page-envelope-audit.test.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Detector unit tests for `page-envelope-audit.ts` (#11255 → #11480). + * + * The detector's behaviour is tested ONCE, here at its home: these are the + * negative controls and door-necessity proofs that landed with the first + * consuming gate (`packages/platform-objects/src/pages/ + * canonical-expression-envelopes.test.ts`) and moved when the detector was + * extracted. What stays in each consuming package is what only that package + * can assert: its population scan, its per-page precondition asserts, and a + * downgrade control over one of its own SHIPPED pages — proof the imported + * detector actually reaches that package's exports. + * + * Two families below: + * + * - **negative controls** — the detector fires on a bare predicate (and only + * on a bare one), naming page + key path, at every position class: a + * top-level slot component, a component nested inside `properties`, the + * opaque props bag itself, and the deprecated `visibility` alias. + * - **door-necessity proofs** — each parse door catches a position the + * others cannot see, shown by running the other doors in isolation over + * the same fixture and getting nothing. `collectBare` is exported (module- + * level, not from the barrel) exactly for these single-door runs. + */ + +import { describe, expect, it } from 'vitest'; +import { PageComponentSchema, PageSchema } from '@objectstack/spec/ui'; +import { walkPageComponents } from './page-walk.js'; +import { + auditPageExpressionEnvelopes, + collectBare, + renderBareExpressionFindings, +} from './page-envelope-audit.js'; +import type { BareExpressionFinding } from './page-envelope-audit.js'; + +type AnyRec = Record; + +/** The minimal zod face the doors are driven through in the isolation runs. */ +interface Parseable { + safeParse(value: unknown): { success: boolean; data?: unknown }; +} + +const BARE = 'has(record.id) && record.id == ctx.user.id'; + +/** A page whose ONE predicate sits on a top-level slot component. */ +function topLevelPredicatePage(visibleWhen: unknown): AnyRec { + return { + name: 'nc_top_level', + label: 'Negative control', + type: 'record', + object: 'sys_user', + template: 'default', + kind: 'slotted', + regions: [], + slots: { alerts: [{ type: 'record:alert', visibleWhen, properties: { severity: 'warning' } }] }, + }; +} + +/** A page whose ONE predicate sits on a component NESTED inside `properties`. */ +function nestedPredicatePage(visibleWhen: unknown): AnyRec { + return { + name: 'nc_nested', + label: 'Negative control', + type: 'record', + object: 'sys_user', + template: 'default', + kind: 'slotted', + regions: [], + slots: { + tabs: { + type: 'page:tabs', + properties: { + items: [ + { + label: { en: 'Tab' }, + children: [{ type: 'record:alert', visibleWhen, properties: { severity: 'warning' } }], + }, + ], + }, + }, + }, + }; +} + +/** A page whose ONE predicate sits INSIDE the opaque `properties` bag. */ +function propsPredicatePage(visible: unknown): AnyRec { + return { + name: 'nc_props', + label: 'Negative control', + type: 'record', + object: 'sys_user', + template: 'default', + kind: 'slotted', + regions: [], + slots: { alerts: [{ type: 'record:alert', properties: { severity: 'warning', visible } }] }, + }; +} + +const envelope = { dialect: 'cel' as const, source: BARE }; + +describe('negative control — the detector fires, and every door earns its place', () => { + it('catches a bare predicate on a top-level component, naming page and key path', () => { + const audit = auditPageExpressionEnvelopes(topLevelPredicatePage(BARE), 'nc (nc_top_level)'); + expect(audit.findings).toHaveLength(1); + expect(audit.findings[0]!.path).toBe('slots.alerts[0].visibleWhen'); + expect(audit.findings[0]!.authored).toBe(BARE); + + const rendered = renderBareExpressionFindings(audit.findings); + expect(rendered).toContain('nc_top_level'); + expect(rendered).toContain('slots.alerts[0].visibleWhen'); + expect(rendered).toContain('authored BARE'); + }); + + it('passes the SAME predicate authored as the canonical envelope (no blanket flagging)', () => { + const audit = auditPageExpressionEnvelopes(topLevelPredicatePage(envelope), 'nc (nc_top_level)'); + expect(renderBareExpressionFindings(audit.findings)).toBe(''); + // The preconditions hold on the control fixtures too, so a green above is a + // real verdict rather than a door that never opened. + expect(audit.pageParseError ?? '').toBe(''); + expect(audit.componentParseErrors).toEqual([]); + }); + + it('catches a bare predicate on a component NESTED inside `properties` — which door 1 alone cannot see', () => { + const page = nestedPredicatePage(BARE); + const audit = auditPageExpressionEnvelopes(page, 'nc (nc_nested)'); + expect(audit.findings.map(f => f.path)).toEqual([ + 'slots.tabs.properties.items[0].children[0].visibleWhen', + ]); + expect(audit.findings[0]!.door).toBe('PageComponentSchema'); + + // Door 1 in isolation: `PageSchema` serves `properties` verbatim, so the + // nested predicate is still a bare string in its own output — nothing to + // compare against, nothing found. This is why door 2 exists. + const doorOneOnly: BareExpressionFinding[] = []; + const parsed = (PageSchema as unknown as Parseable).safeParse(page); + expect(parsed.success).toBe(true); + collectBare(page, parsed.data, '', 'nc', 'PageSchema', doorOneOnly); + expect(doorOneOnly).toEqual([]); + }); + + it('catches a bare predicate inside the `properties` bag — which doors 1 and 2 alone cannot see', () => { + const page = propsPredicatePage(BARE); + const audit = auditPageExpressionEnvelopes(page, 'nc (nc_props)'); + expect(audit.findings.map(f => f.path)).toEqual([ + 'slots.alerts[0].properties.visible', + ]); + expect(audit.findings[0]!.door).toBe('ComponentPropsMap'); + + // Doors 1 and 2 both treat `properties` as an opaque record. + const doorsOneTwo: BareExpressionFinding[] = []; + const parsedPage = (PageSchema as unknown as Parseable).safeParse(page); + collectBare(page, parsedPage.data, '', 'nc', 'PageSchema', doorsOneTwo); + for (const { component, path } of walkPageComponents(page, '')) { + const parsedComponent = (PageComponentSchema as unknown as Parseable).safeParse(component); + collectBare(component, parsedComponent.data, path.replace(/^\./, ''), 'nc', 'PageComponentSchema', doorsOneTwo); + } + expect(doorsOneTwo).toEqual([]); + }); + + it('catches a bare predicate at the DEPRECATED `visibility` key and names the canonical one', () => { + const page = { + name: 'nc_alias', + label: 'Negative control', + type: 'record', + object: 'sys_user', + template: 'default', + kind: 'slotted', + regions: [], + slots: { alerts: [{ type: 'record:alert', visibility: BARE, properties: { severity: 'warning' } }] }, + }; + const audit = auditPageExpressionEnvelopes(page, 'nc (nc_alias)'); + expect(audit.findings.map(f => f.path)).toEqual(['slots.alerts[0].visibility']); + expect(audit.findings[0]!.normalizedTo).toBe('visibleWhen'); + expect(renderBareExpressionFindings(audit.findings)).toContain('deprecated key'); + }); +}); diff --git a/packages/lint/src/page-envelope-audit.ts b/packages/lint/src/page-envelope-audit.ts new file mode 100644 index 0000000000..1bf3b77910 --- /dev/null +++ b/packages/lint/src/page-envelope-audit.ts @@ -0,0 +1,343 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Canonical-expression-envelope audit for raw-literal `Page` exports + * (#11255 → #11480). + * + * ## The hazard this detects + * + * `PageComponentSchema.visibleWhen` is an `ExpressionInputSchema`, whose + * transform normalizes a bare string into the canonical + * `{ dialect: 'cel', source }` envelope. **That transform only runs if + * something parses the page.** A page authored as a raw typed object literal — + * + * ```ts + * export const SysUserDetailPage: Page = { … } + * ``` + * + * — is type-checked and never *parsed*, so whatever the author wrote reaches + * `/api/v1/meta/page` verbatim. A page built through `definePage()` is parsed + * and serves the envelope. + * + * Bare is not cosmetic. objectui's `ExpressionEvaluator.evaluateCondition` + * routes by SHAPE — bare strings stay on the legacy JS evaluator for its + * back-compat window, and only an explicit `{ dialect: 'cel' }` envelope is + * rerouted to CEL. The legacy evaluator has no `has()`, component visibility + * is fail-**soft**, so a guarded predicate throws and resolves to SHOWN: a + * declared gate stops gating. In a production console bundle it is completely + * silent — `SchemaRenderer`'s diagnostic probe sits behind `if (__DEV__)`. + * + * Authoring-time signals are all green while this is true: the type accepts + * the bare string, `tsc` passes, every test passes, the gate farm passes. + * That is what makes this worth a gate rather than a review habit. + * + * ## Why the detector lives HERE, once + * + * The first gate on this class was package-local to `platform-objects` (a + * `packages/spec` test reading platform-objects sources would trip + * `check:cross-package-test-inputs`), which left every raw-literal page in + * every OTHER published package unreachable — #11480 records one, found in a + * `*-ui.ts` file no `*.page.ts` sweep ever looked at. Copying the detector + * per package is the failure mode `page-walk.ts`'s own header documents as + * having produced a dead rule, so the detector lives beside that walk and + * each owning package runs a thin test over its own `Page` exports + * (`packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts` + * is the reference consumer — its header carries the population discipline a + * consuming gate owes: export-shape discovery, coverage floor, precondition + * asserts). + * + * ## Why THREE parse doors, and why each is load-bearing + * + * There is no single parse that reaches every expression position on a page, + * so {@link auditPageExpressionEnvelopes} uses three and unions their + * findings. `page-envelope-audit.test.ts` proves each one catches something + * the others miss — none is decoration: + * + * | door | parses | reaches | blind to | + * |:--|:--|:--|:--| + * | 1 `PageSchema` | the whole page | every schema-typed position (component `visibleWhen`, and any expression key nested in a typed sub-schema) | anything inside `properties` | + * | 2 `PageComponentSchema` | each walked component | components nested INSIDE `properties` (`page:tabs` → `items[].children[]`, `page:card` → `body`/`footer`) | the `properties` bag itself | + * | 3 `ComponentPropsMap[type]` | each component's `properties` | expression keys the per-type props schema declares (`record:alert.properties.visible`) | types absent from the map | + * + * Door 2 exists because `PageComponentSchema.properties` is + * `z.record(z.unknown())` — an opaque bag served verbatim. Door 1 walks + * straight past a whole nested component tree, so a bare predicate on a tab's + * child is invisible to it. Door 3 exists because that same opacity means a + * props-level predicate never reaches `ExpressionInputSchema` at all: + * `record:alert` declares `properties.visible`, and a bare one there hits the + * legacy evaluator exactly like a bare node `visibleWhen`. + * + * ## How a position is IDENTIFIED — behaviourally, not by name + * + * Nothing here hardcodes `visibleWhen`. Each door walks the authored object + * and its parsed counterpart in lockstep and flags exactly one shape: **the + * author wrote a string, and the parse turned that same string into an + * expression envelope**. That is the observable signature of an + * `ExpressionInputSchema` position and of nothing else, so the audit covers + * `visibility`, every dialect (`cel` / `cron` / `template`), and any + * expression key added later — without an edit here. Keys that parse merely + * MATERIALIZES (defaults) are absent from the authored side and so are never + * flagged. The deprecated-alias case is caught too: when the parse consumed + * an authored key and re-homed its value under the canonical name, the + * finding reports both. + * + * ## Preconditions are REPORTED, never assumed + * + * A door that cannot parse reports nothing, which is indistinguishable from + * "clean" — the silent-coverage-shrink shape. So the audit reports every + * door's failure to open (`pageParseError`, `componentParseErrors`, + * `unmappedTypes`, `unreadableProps`) alongside its findings, and a consuming + * gate must assert each of those channels — as its own test, so the red says + * which door just stopped reading rather than the gate going quietly green + * over a population it no longer covers. An `unmappedTypes` entry may be a + * deliberate exemption (a console-registered widget type with no + * `ComponentPropsMap` row); a consumer records that exemption in its own + * assert, with the reason, never by ignoring the channel. + * + * @see packages/spec/src/shared/expression.zod.ts — `ExpressionInputSchema` + * @see packages/spec/src/ui/page.zod.ts — `PageComponentSchema.visibleWhen` + */ + +import { ComponentPropsMap, PageComponentSchema, PageSchema } from '@objectstack/spec/ui'; +import { walkPageComponents } from './page-walk.js'; + +type AnyRec = Record; + +const isRec = (v: unknown): v is AnyRec => + !!v && typeof v === 'object' && !Array.isArray(v); + +/** + * An expression envelope, as much of one as this audit needs to recognise: + * `ExpressionSchema` requires `dialect` and at least one of `source` / `ast`. + */ +const isEnvelope = (v: unknown): v is { dialect: string; source?: unknown } => + isRec(v) && typeof v.dialect === 'string'; + +/** Which parse produced a finding — see the door table in the module header. */ +export type EnvelopeAuditDoor = 'PageSchema' | 'PageComponentSchema' | 'ComponentPropsMap'; + +/** One bare-expression position: authored as a string, normalized by a parse. */ +export interface BareExpressionFinding { + /** The consumer's page label, e.g. ` ()`. */ + page: string; + /** Authored path from the page root, e.g. `slots.alerts[0].visibleWhen`. */ + path: string; + /** The bare string the author wrote — what reaches the wire verbatim. */ + authored: string; + /** Set when the parse also RENAMED the key (deprecated alias). */ + normalizedTo?: string; + door: EnvelopeAuditDoor; +} + +/** + * A minimal zod-schema face — all this module calls on the schemas it reads. + * Structural on purpose: the audit compares parse OUTPUT against authored + * input and never touches zod's own types, so a zod major cannot ripple here. + */ +interface Parseable { + safeParse(value: unknown): { + success: boolean; + data?: unknown; + error?: { issues: ReadonlyArray<{ path: PropertyKey[]; message: string }> }; + }; +} + +const PROPS_SCHEMAS = ComponentPropsMap as unknown as Record; + +function issueText(error: { issues: ReadonlyArray<{ path: PropertyKey[]; message: string }> } | undefined): string { + if (!error) return '(no issues reported)'; + return error.issues + .map(i => `${i.path.map(String).join('.') || '(root)'}: ${i.message}`) + .join(' | '); +} + +/** + * Walk an authored value and its parsed counterpart in lockstep, collecting + * every position where the author wrote a string that the parse turned into + * an expression envelope. + * + * Iteration is driven by the AUTHORED side, deliberately: keys the parse + * materialized (defaults) have no authored counterpart and must not be read + * as findings. + * + * @internal Package-internal (not re-exported from the barrel): a consumer + * always wants the three-door union {@link auditPageExpressionEnvelopes}; + * this single-door primitive exists so `page-envelope-audit.test.ts` can + * prove door necessity — running one door in isolation and showing what it + * alone cannot see. + */ +export function collectBare( + raw: unknown, + parsed: unknown, + path: string, + page: string, + door: EnvelopeAuditDoor, + out: BareExpressionFinding[], +): void { + if (typeof raw === 'string') { + if (isEnvelope(parsed) && parsed.source === raw) { + out.push({ page, path, authored: raw, door }); + } + return; + } + if (Array.isArray(raw)) { + if (!Array.isArray(parsed)) return; + for (let i = 0; i < raw.length; i++) { + collectBare(raw[i], parsed[i], `${path}[${i}]`, page, door, out); + } + return; + } + if (!isRec(raw) || !isRec(parsed)) return; + + for (const [key, value] of Object.entries(raw)) { + const childPath = path ? `${path}.${key}` : key; + const counterpart = parsed[key]; + + // Deprecated-alias case: the parse consumed this key and re-homed its + // value under the canonical name (`visibility` -> `visibleWhen`). A + // key-parallel walk alone would see `undefined` on the parsed side and + // move on, so the value is looked up by its own source text instead. + if (counterpart === undefined && typeof value === 'string' && value.length > 0) { + const renamed = Object.entries(parsed).find( + ([, pv]) => isEnvelope(pv) && pv.source === value, + ); + if (renamed) { + out.push({ page, path: childPath, authored: value, normalizedTo: renamed[0], door }); + continue; + } + } + + collectBare(value, counterpart, childPath, page, door, out); + } +} + +/** The three-door union over one page, findings and door preconditions both. */ +export interface PageEnvelopeAudit { + /** Every bare-expression position any door found, deduped by path, sorted. */ + findings: BareExpressionFinding[]; + /** Door 1 could not run — the page itself does not parse. */ + pageParseError?: string; + /** Door 2 could not run for these components. */ + componentParseErrors: { path: string; type: string; issues: string }[]; + /** Door 3 has no schema to parse these components' `properties` with. */ + unmappedTypes: { path: string; type: string }[]; + /** Door 3 could not run — the authored bag is refused by its props schema. */ + unreadableProps: { path: string; type: string; issues: string }[]; + /** How many components the walk reached (coverage floor for doors 2 and 3). */ + componentCount: number; +} + +/** + * Run all three parse doors over one authored page and union their findings. + * + * Read `findings` for the verdict, and assert the four precondition channels + * separately (see the module header — a door that could not open reports + * there, never as a silently smaller `findings`). + * + * Exported findings are deduped by path: a top-level component's + * `visibleWhen` is legitimately seen by doors 1 AND 2, and reporting it twice + * would read as two defects. + * + * @param page - The authored page object, exactly as exported (never parsed + * first — the audit's whole subject is what the raw export serves). + * @param pageLabel - How findings should name the page; consumers use + * ` ()`. + */ +export function auditPageExpressionEnvelopes(page: unknown, pageLabel: string): PageEnvelopeAudit { + const audit: PageEnvelopeAudit = { + findings: [], + componentParseErrors: [], + unmappedTypes: [], + unreadableProps: [], + componentCount: 0, + }; + if (!isRec(page)) { + audit.pageParseError = 'not an object'; + return audit; + } + + const raw: BareExpressionFinding[] = []; + + // ── Door 1 — the whole page through `PageSchema` ─────────────────────────── + const parsedPage = (PageSchema as unknown as Parseable).safeParse(page); + if (parsedPage.success) { + collectBare(page, parsedPage.data, '', pageLabel, 'PageSchema', raw); + } else { + audit.pageParseError = issueText(parsedPage.error); + } + + // ── Doors 2 & 3 — every component the page walk reaches ──────────────────── + // `walkPageComponents` is the one shared traversal: it knows components hang + // off `regions[].components[]` and `slots.` (a slot holds ONE + // component or an array), and it descends the untyped sub-trees inside + // `properties`. Duplicating that walk is how a rule built on it goes dead + // (its own header carries the incident), so it is imported rather than + // rewritten. + const walked = walkPageComponents(page, ''); + audit.componentCount = walked.length; + + for (const { component, path: walkedPath } of walked) { + const componentPath = walkedPath.replace(/^\./, ''); + const type = typeof component.type === 'string' ? component.type : '(non-string type)'; + + const parsedComponent = (PageComponentSchema as unknown as Parseable).safeParse(component); + if (parsedComponent.success) { + collectBare(component, parsedComponent.data, componentPath, pageLabel, 'PageComponentSchema', raw); + } else { + audit.componentParseErrors.push({ path: componentPath, type, issues: issueText(parsedComponent.error) }); + } + + const propsSchema = PROPS_SCHEMAS[type]; + if (!propsSchema) { + audit.unmappedTypes.push({ path: componentPath, type }); + continue; + } + // A component may author no `properties` at all (`element:divider`). + // Nothing authored is nothing to serve bare, so there is no door to open. + if (!isRec(component.properties)) continue; + + const parsedProps = propsSchema.safeParse(component.properties); + if (parsedProps.success) { + collectBare( + component.properties, + parsedProps.data, + `${componentPath}.properties`, + pageLabel, + 'ComponentPropsMap', + raw, + ); + } else { + audit.unreadableProps.push({ path: componentPath, type, issues: issueText(parsedProps.error) }); + } + } + + const seen = new Set(); + for (const finding of raw) { + const key = `${finding.page}::${finding.path}`; + if (seen.has(key)) continue; + seen.add(key); + audit.findings.push(finding); + } + audit.findings.sort((a, b) => a.path.localeCompare(b.path)); + return audit; +} + +/** Render findings as the actionable red an author reads in CI. */ +export function renderBareExpressionFindings(findings: readonly BareExpressionFinding[]): string { + return findings + .map(f => { + const renamed = f.normalizedTo + ? ` (deprecated key — parse re-homes it to \`${f.normalizedTo}\`)` + : ''; + return ( + `${f.page} · ${f.path}${renamed}\n` + + ` authored BARE: ${JSON.stringify(f.authored)}\n` + + ` seen by: ${f.door}\n` + + ' fix: author the canonical envelope — P`…` (or { dialect: \'cel\', source: … }).\n' + + ' This page is a raw object literal: nothing parses it, so a bare string\n' + + ' reaches the wire verbatim and the console routes it to its LEGACY\n' + + ' evaluator, where a fail-soft predicate stops gating silently.' + ); + }) + .join('\n\n'); +} diff --git a/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts b/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts index d7f1874bbd..e10c968e05 100644 --- a/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts +++ b/packages/platform-objects/src/pages/canonical-expression-envelopes.test.ts @@ -4,86 +4,42 @@ * Gate — every `Page` this package exports must serve the CANONICAL * `{ dialect, source }` envelope at every `ExpressionInputSchema` position. * - * ## The hazard this closes - * - * `PageComponentSchema.visibleWhen` is an `ExpressionInputSchema`, whose - * transform normalizes a bare string into `{ dialect: 'cel', source }`. **That - * transform only runs if something parses the page.** A page authored as a raw - * typed object literal — - * - * ```ts - * export const SysUserDetailPage: Page = { … } - * ``` - * - * — is type-checked and never *parsed*, so whatever the author wrote reaches - * `/api/v1/meta/page` verbatim. Every page in this package is authored that - * way; a page built through `definePage()` is parsed and serves the envelope. - * - * Bare is not cosmetic. objectui's `ExpressionEvaluator.evaluateCondition` - * routes by SHAPE — bare strings stay on the legacy JS evaluator for its - * back-compat window, and only an explicit `{ dialect: 'cel' }` envelope is - * rerouted to CEL. The legacy evaluator has no `has()`, component visibility is - * fail-**soft**, so a guarded predicate throws and resolves to SHOWN: a declared - * gate stops gating. In a production console bundle it is completely silent — - * `SchemaRenderer`'s diagnostic probe sits behind `if (__DEV__)`. - * - * Authoring-time signals are all green while this is true: the type accepts the - * bare string, `tsc` passes, every test passes, the gate farm passes. That is - * what makes this worth a gate rather than a review habit. - * - * ## Why THREE parse doors, and why each is load-bearing - * - * There is no single parse that reaches every expression position on a page, so - * this gate uses three and unions their findings. `negative control` below - * proves each one catches something the others miss — none is decoration: - * - * | door | parses | reaches | blind to | - * |:--|:--|:--|:--| - * | 1 `PageSchema` | the whole page | every schema-typed position (component `visibleWhen`, and any expression key nested in a typed sub-schema) | anything inside `properties` | - * | 2 `PageComponentSchema` | each walked component | components nested INSIDE `properties` (`page:tabs` → `items[].children[]`, `page:card` → `body`/`footer`) | the `properties` bag itself | - * | 3 `ComponentPropsMap[type]` | each component's `properties` | expression keys the per-type props schema declares (`record:alert.properties.visible`) | types absent from the map | - * - * Door 2 exists because `PageComponentSchema.properties` is - * `z.record(z.unknown())` — an opaque bag served verbatim. Door 1 walks straight - * past a whole nested component tree, so a bare predicate on a tab's child is - * invisible to it. Door 3 exists because that same opacity means a props-level - * predicate never reaches `ExpressionInputSchema` at all: `record:alert` - * declares `properties.visible`, and a bare one there hits the legacy evaluator - * exactly like a bare node `visibleWhen`. - * - * ## How a position is IDENTIFIED — behaviourally, not by name - * - * Nothing here hardcodes `visibleWhen`. Each door walks the authored object and - * its parsed counterpart in lockstep and flags exactly one shape: **the author - * wrote a string, and the parse turned that same string into an expression - * envelope**. That is the observable signature of an `ExpressionInputSchema` - * position and of nothing else, so the gate covers `visibility`, every dialect - * (`cel` / `cron` / `template`), and any expression key added later — without an - * edit here. Keys that parse merely MATERIALIZES (defaults) are absent from the - * authored side and so are never flagged. - * - * The deprecated-alias case is caught too: when the parse consumed an authored - * key and re-homed its value under the canonical name, the finding reports both. - * - * ## The preconditions are asserted, not assumed - * - * A door that cannot parse reports nothing, which is indistinguishable from - * "clean" — the silent-coverage-shrink shape. So each door's precondition is its - * own test: the page parses, every component parses, every component type is - * mapped, every authored props bag parses. If one of those ever goes red, the - * message says which door just stopped reading, rather than this file going - * quietly green over a population it no longer covers. - * + * The detector — the three parse doors, the behavioural lockstep walk that + * identifies an expression position without hardcoding key names, and the + * finding renderer — lives in `@objectstack/lint` (`page-envelope-audit.ts`; + * #11255 → #11480). Its header carries the hazard (a raw-literal page is + * type-checked, never PARSED, so bare predicates reach the wire verbatim and + * the console silently routes them to its legacy fail-soft evaluator), the + * door table, and the negative controls proving each door is load-bearing. + * It moved there because this file was the detector's first home and a + * package-local detector cannot reach the raw-literal pages OTHER published + * packages ship — #11480 records one in a `*-ui.ts` file no `*.page.ts` + * sweep ever looked at. + * + * What stays HERE is what only this package can assert: + * + * - **the population** — every `export const X: Page =` declared anywhere in + * this package's `src/` is covered (scanned from source text, because the + * hazard is precisely the page nobody wired into the barrel), with a floor + * so the gate cannot silently read nothing; + * - **the door preconditions, per page** — a door that cannot parse reports + * nothing, which is indistinguishable from "clean", so each channel is its + * own test naming which door just stopped reading; + * - **the verdict** — no exported page authors a bare expression string; + * - **a downgrade control over a SHIPPED page** — proof the imported + * detector actually reaches this package's real exports, not only its own + * fixtures (and the reverse-verification surface: ablate the lint module + * and this is the test that reds). + * + * @see packages/lint/src/page-envelope-audit.ts — the detector * @see packages/spec/src/shared/expression.zod.ts — `ExpressionInputSchema` - * @see packages/spec/src/ui/page.zod.ts — `PageComponentSchema.visibleWhen` */ import { readFileSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { ComponentPropsMap, PageComponentSchema, PageSchema } from '@objectstack/spec/ui'; import type { Page } from '@objectstack/spec/ui'; -import { walkPageComponents } from '@objectstack/lint'; +import { auditPageExpressionEnvelopes, renderBareExpressionFindings } from '@objectstack/lint'; import * as pageExports from './index.js'; type AnyRec = Record; @@ -91,219 +47,6 @@ type AnyRec = Record; const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); -/** - * An expression envelope, as much of one as this file needs to recognise: - * `ExpressionSchema` requires `dialect` and at least one of `source` / `ast`. - */ -const isEnvelope = (v: unknown): v is { dialect: string; source?: unknown } => - isRec(v) && typeof v.dialect === 'string'; - -/** Which parse produced a finding — see the door table in the header. */ -type Door = 'PageSchema' | 'PageComponentSchema' | 'ComponentPropsMap'; - -interface BareExpressionFinding { - /** ` ()`. */ - page: string; - /** Authored path from the page root, e.g. `slots.alerts[0].visibleWhen`. */ - path: string; - /** The bare string the author wrote — what reaches the wire verbatim. */ - authored: string; - /** Set when the parse also RENAMED the key (deprecated alias). */ - normalizedTo?: string; - door: Door; -} - -/** A minimal zod-schema face — all this file calls on the schemas it is handed. */ -interface Parseable { - safeParse(value: unknown): { - success: boolean; - data?: unknown; - error?: { issues: ReadonlyArray<{ path: PropertyKey[]; message: string }> }; - }; -} - -const PROPS_SCHEMAS = ComponentPropsMap as unknown as Record; - -function issueText(error: { issues: ReadonlyArray<{ path: PropertyKey[]; message: string }> } | undefined): string { - if (!error) return '(no issues reported)'; - return error.issues - .map(i => `${i.path.map(String).join('.') || '(root)'}: ${i.message}`) - .join(' | '); -} - -/** - * Walk an authored value and its parsed counterpart in lockstep, collecting - * every position where the author wrote a string that the parse turned into an - * expression envelope. - * - * Iteration is driven by the AUTHORED side, deliberately: keys the parse - * materialized (defaults) have no authored counterpart and must not be read as - * findings. - */ -function collectBare( - raw: unknown, - parsed: unknown, - path: string, - page: string, - door: Door, - out: BareExpressionFinding[], -): void { - if (typeof raw === 'string') { - if (isEnvelope(parsed) && parsed.source === raw) { - out.push({ page, path, authored: raw, door }); - } - return; - } - if (Array.isArray(raw)) { - if (!Array.isArray(parsed)) return; - for (let i = 0; i < raw.length; i++) { - collectBare(raw[i], parsed[i], `${path}[${i}]`, page, door, out); - } - return; - } - if (!isRec(raw) || !isRec(parsed)) return; - - for (const [key, value] of Object.entries(raw)) { - const childPath = path ? `${path}.${key}` : key; - const counterpart = parsed[key]; - - // Deprecated-alias case: the parse consumed this key and re-homed its value - // under the canonical name (`visibility` -> `visibleWhen`). A key-parallel - // walk alone would see `undefined` on the parsed side and move on, so the - // value is looked up by its own source text instead. - if (counterpart === undefined && typeof value === 'string' && value.length > 0) { - const renamed = Object.entries(parsed).find( - ([, pv]) => isEnvelope(pv) && pv.source === value, - ); - if (renamed) { - out.push({ page, path: childPath, authored: value, normalizedTo: renamed[0], door }); - continue; - } - } - - collectBare(value, counterpart, childPath, page, door, out); - } -} - -interface PageAudit { - findings: BareExpressionFinding[]; - /** Door 1 could not run — the page itself does not parse. */ - pageParseError?: string; - /** Door 2 could not run for these components. */ - componentParseErrors: { path: string; type: string; issues: string }[]; - /** Door 3 has no schema to parse these components' `properties` with. */ - unmappedTypes: { path: string; type: string }[]; - /** Door 3 could not run — the authored bag is refused by its props schema. */ - unreadableProps: { path: string; type: string; issues: string }[]; - /** How many components the walk reached (coverage floor for doors 2 and 3). */ - componentCount: number; -} - -/** - * Run all three doors over one authored page. - * - * Exported findings are deduped by path: a top-level component's `visibleWhen` - * is legitimately seen by doors 1 AND 2, and reporting it twice would read as - * two defects. - */ -function auditPage(page: unknown, pageLabel: string): PageAudit { - const audit: PageAudit = { - findings: [], - componentParseErrors: [], - unmappedTypes: [], - unreadableProps: [], - componentCount: 0, - }; - if (!isRec(page)) { - audit.pageParseError = 'not an object'; - return audit; - } - - const raw: BareExpressionFinding[] = []; - - // ── Door 1 — the whole page through `PageSchema` ─────────────────────────── - const parsedPage = (PageSchema as unknown as Parseable).safeParse(page); - if (parsedPage.success) { - collectBare(page, parsedPage.data, '', pageLabel, 'PageSchema', raw); - } else { - audit.pageParseError = issueText(parsedPage.error); - } - - // ── Doors 2 & 3 — every component the page walk reaches ──────────────────── - // `walkPageComponents` is the one shared traversal (`@objectstack/lint`): it - // knows components hang off `regions[].components[]` and `slots.` (a - // slot holds ONE component or an array), and it descends the untyped - // sub-trees inside `properties`. Duplicating that walk here is how a rule - // built on it goes dead, so it is imported rather than rewritten. - const walked = walkPageComponents(page, ''); - audit.componentCount = walked.length; - - for (const { component, path: walkedPath } of walked) { - const componentPath = walkedPath.replace(/^\./, ''); - const type = typeof component.type === 'string' ? component.type : '(non-string type)'; - - const parsedComponent = (PageComponentSchema as unknown as Parseable).safeParse(component); - if (parsedComponent.success) { - collectBare(component, parsedComponent.data, componentPath, pageLabel, 'PageComponentSchema', raw); - } else { - audit.componentParseErrors.push({ path: componentPath, type, issues: issueText(parsedComponent.error) }); - } - - const propsSchema = PROPS_SCHEMAS[type]; - if (!propsSchema) { - audit.unmappedTypes.push({ path: componentPath, type }); - continue; - } - // A component may author no `properties` at all (`element:divider`). - // Nothing authored is nothing to serve bare, so there is no door to open. - if (!isRec(component.properties)) continue; - - const parsedProps = propsSchema.safeParse(component.properties); - if (parsedProps.success) { - collectBare( - component.properties, - parsedProps.data, - `${componentPath}.properties`, - pageLabel, - 'ComponentPropsMap', - raw, - ); - } else { - audit.unreadableProps.push({ path: componentPath, type, issues: issueText(parsedProps.error) }); - } - } - - const seen = new Set(); - for (const finding of raw) { - const key = `${finding.page}::${finding.path}`; - if (seen.has(key)) continue; - seen.add(key); - audit.findings.push(finding); - } - audit.findings.sort((a, b) => a.path.localeCompare(b.path)); - return audit; -} - -/** Render findings as the actionable red an author reads in CI. */ -function renderFindings(findings: readonly BareExpressionFinding[]): string { - return findings - .map(f => { - const renamed = f.normalizedTo - ? ` (deprecated key — parse re-homes it to \`${f.normalizedTo}\`)` - : ''; - return ( - `${f.page} · ${f.path}${renamed}\n` - + ` authored BARE: ${JSON.stringify(f.authored)}\n` - + ` seen by: ${f.door}\n` - + ' fix: author the canonical envelope — P`…` (or { dialect: \'cel\', source: … }).\n' - + ' This page is a raw object literal: nothing parses it, so a bare string\n' - + ' reaches the wire verbatim and the console routes it to its LEGACY\n' - + ' evaluator, where a fail-soft predicate stops gating silently.' - ); - }) - .join('\n\n'); -} - // ─────────────────────────────────────────────────────────────────────────── // The population this gate covers // ─────────────────────────────────────────────────────────────────────────── @@ -376,7 +119,7 @@ function declaredPageExports(): { name: string; file: string }[] { const AUDITS = EXPORTED_PAGES.map(([exportName, page]) => ({ exportName, page, - audit: auditPage(page, pageLabel(exportName, page)), + audit: auditPageExpressionEnvelopes(page, pageLabel(exportName, page)), })); // ─────────────────────────────────────────────────────────────────────────── @@ -437,153 +180,24 @@ describe('platform Page exports serve canonical expression envelopes', () => { }); it.each(AUDITS)('$exportName authors NO bare expression string', ({ audit }) => { - expect(renderFindings(audit.findings)).toBe(''); + expect(renderBareExpressionFindings(audit.findings)).toBe(''); }); }); // ─────────────────────────────────────────────────────────────────────────── -// Negative control — proof the detector fires, and that each door is needed +// Downgrade control — the imported detector reaches this package's REAL pages // ─────────────────────────────────────────────────────────────────────────── -const BARE = 'has(record.id) && record.id == ctx.user.id'; - -/** A page whose ONE predicate sits on a top-level slot component. */ -function topLevelPredicatePage(visibleWhen: unknown): AnyRec { - return { - name: 'nc_top_level', - label: 'Negative control', - type: 'record', - object: 'sys_user', - template: 'default', - kind: 'slotted', - regions: [], - slots: { alerts: [{ type: 'record:alert', visibleWhen, properties: { severity: 'warning' } }] }, - }; -} - -/** A page whose ONE predicate sits on a component NESTED inside `properties`. */ -function nestedPredicatePage(visibleWhen: unknown): AnyRec { - return { - name: 'nc_nested', - label: 'Negative control', - type: 'record', - object: 'sys_user', - template: 'default', - kind: 'slotted', - regions: [], - slots: { - tabs: { - type: 'page:tabs', - properties: { - items: [ - { - label: { en: 'Tab' }, - children: [{ type: 'record:alert', visibleWhen, properties: { severity: 'warning' } }], - }, - ], - }, - }, - }, - }; -} - -/** A page whose ONE predicate sits INSIDE the opaque `properties` bag. */ -function propsPredicatePage(visible: unknown): AnyRec { - return { - name: 'nc_props', - label: 'Negative control', - type: 'record', - object: 'sys_user', - template: 'default', - kind: 'slotted', - regions: [], - slots: { alerts: [{ type: 'record:alert', properties: { severity: 'warning', visible } }] }, - }; -} - -const envelope = { dialect: 'cel' as const, source: BARE }; - -describe('negative control — the detector fires, and every door earns its place', () => { - it('catches a bare predicate on a top-level component, naming page and key path', () => { - const audit = auditPage(topLevelPredicatePage(BARE), 'nc (nc_top_level)'); - expect(audit.findings).toHaveLength(1); - expect(audit.findings[0]!.path).toBe('slots.alerts[0].visibleWhen'); - expect(audit.findings[0]!.authored).toBe(BARE); - - const rendered = renderFindings(audit.findings); - expect(rendered).toContain('nc_top_level'); - expect(rendered).toContain('slots.alerts[0].visibleWhen'); - expect(rendered).toContain('authored BARE'); - }); - - it('passes the SAME predicate authored as the canonical envelope (no blanket flagging)', () => { - const audit = auditPage(topLevelPredicatePage(envelope), 'nc (nc_top_level)'); - expect(renderFindings(audit.findings)).toBe(''); - // The preconditions hold on the control fixtures too, so a green above is a - // real verdict rather than a door that never opened. - expect(audit.pageParseError ?? '').toBe(''); - expect(audit.componentParseErrors).toEqual([]); - }); - - it('catches a bare predicate on a component NESTED inside `properties` — which door 1 alone cannot see', () => { - const page = nestedPredicatePage(BARE); - const audit = auditPage(page, 'nc (nc_nested)'); - expect(audit.findings.map(f => f.path)).toEqual([ - 'slots.tabs.properties.items[0].children[0].visibleWhen', - ]); - expect(audit.findings[0]!.door).toBe('PageComponentSchema'); - - // Door 1 in isolation: `PageSchema` serves `properties` verbatim, so the - // nested predicate is still a bare string in its own output — nothing to - // compare against, nothing found. This is why door 2 exists. - const doorOneOnly: BareExpressionFinding[] = []; - const parsed = (PageSchema as unknown as Parseable).safeParse(page); - expect(parsed.success).toBe(true); - collectBare(page, parsed.data, '', 'nc', 'PageSchema', doorOneOnly); - expect(doorOneOnly).toEqual([]); - }); - - it('catches a bare predicate inside the `properties` bag — which doors 1 and 2 alone cannot see', () => { - const page = propsPredicatePage(BARE); - const audit = auditPage(page, 'nc (nc_props)'); - expect(audit.findings.map(f => f.path)).toEqual([ - 'slots.alerts[0].properties.visible', - ]); - expect(audit.findings[0]!.door).toBe('ComponentPropsMap'); - - // Doors 1 and 2 both treat `properties` as an opaque record. - const doorsOneTwo: BareExpressionFinding[] = []; - const parsedPage = (PageSchema as unknown as Parseable).safeParse(page); - collectBare(page, parsedPage.data, '', 'nc', 'PageSchema', doorsOneTwo); - for (const { component, path } of walkPageComponents(page, '')) { - const parsedComponent = (PageComponentSchema as unknown as Parseable).safeParse(component); - collectBare(component, parsedComponent.data, path.replace(/^\./, ''), 'nc', 'PageComponentSchema', doorsOneTwo); - } - expect(doorsOneTwo).toEqual([]); - }); - - it('catches a bare predicate at the DEPRECATED `visibility` key and names the canonical one', () => { - const page = { - name: 'nc_alias', - label: 'Negative control', - type: 'record', - object: 'sys_user', - template: 'default', - kind: 'slotted', - regions: [], - slots: { alerts: [{ type: 'record:alert', visibility: BARE, properties: { severity: 'warning' } }] }, - }; - const audit = auditPage(page, 'nc (nc_alias)'); - expect(audit.findings.map(f => f.path)).toEqual(['slots.alerts[0].visibility']); - expect(audit.findings[0]!.normalizedTo).toBe('visibleWhen'); - expect(renderFindings(audit.findings)).toContain('deprecated key'); - }); - +describe('downgrade control — a shipped page, predicate downgraded to bare', () => { it('flags a REAL exported page the moment its predicate is down-graded to bare', () => { // The operative acceptance criterion, exercised against the shipped page // rather than a fixture: take the real export, replace its one canonical // envelope with the bare source it wraps, and confirm the gate reds with a // path an author can act on. Deep-cloned — the export itself is untouched. + // (The detector's own fixture-based negative controls live at its home, + // `packages/lint/src/page-envelope-audit.test.ts`; this control is the + // half only this package can run, and the one that reds if the lint + // detector is ablated or its import breaks.) const source = JSON.parse(JSON.stringify(pageExports.SysUserDetailPage)) as AnyRec; const slots = source.slots as AnyRec; const alerts = slots.alerts as AnyRec[]; @@ -596,18 +210,18 @@ describe('negative control — the detector fires, and every door earns its plac ).toBe('string'); alerts[0]!.visibleWhen = authored; - const audit = auditPage(source, pageLabel('SysUserDetailPage', source as unknown as Page)); + const audit = auditPageExpressionEnvelopes(source, pageLabel('SysUserDetailPage', source as unknown as Page)); expect(audit.findings.map(f => f.path)).toEqual(['slots.alerts[0].visibleWhen']); - const rendered = renderFindings(audit.findings); + const rendered = renderBareExpressionFindings(audit.findings); expect(rendered).toContain('sys_user_detail'); expect(rendered).toContain('slots.alerts[0].visibleWhen'); // …and the untouched export is still clean, so the red above came from the // mutation and not from something this test left behind. - const pristine = auditPage( + const pristine = auditPageExpressionEnvelopes( pageExports.SysUserDetailPage, pageLabel('SysUserDetailPage', pageExports.SysUserDetailPage), ); - expect(renderFindings(pristine.findings)).toBe(''); + expect(renderBareExpressionFindings(pristine.findings)).toBe(''); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfade84f8f..acab809f60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -702,6 +702,9 @@ importers: specifier: workspace:* version: link:../types devDependencies: + '@objectstack/lint': + specifier: workspace:* + version: link:../lint '@types/node': specifier: ^26.2.0 version: 26.2.0