Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/envelope-audit-shared-home.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/cloud-connection/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@
"@objectstack/types": "workspace:*"
},
"devDependencies": {
"@objectstack/lint": "workspace:*",
"@types/node": "^26.2.0",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
Expand Down
258 changes: 258 additions & 0 deletions packages/cloud-connection/src/canonical-expression-envelopes.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;

/** 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('');
});
});
12 changes: 10 additions & 2 deletions packages/cloud-connection/src/marketplace-ui.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand All@@ -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,
Expand Down
40 changes: 40 additions & 0 deletions packages/cloud-connection/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -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.
});
22 changes: 22 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Loading
Loading