Skip to content

Commit 12e306a

Browse files
os-steveclaude
andauthored
feat(lint): extract the canonical-envelope page audit into @objectstack/lint; gate cloud-connection's pages (#11574)
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. Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 348860c commit 12e306a

10 files changed

Lines changed: 901 additions & 429 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/cloud-connection': patch
4+
---
5+
6+
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.

packages/cloud-connection/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@objectstack/types": "workspace:*"
2525
},
2626
"devDependencies": {
27+
"@objectstack/lint": "workspace:*",
2728
"@types/node": "^26.2.0",
2829
"typescript": "^6.0.3",
2930
"vitest": "^4.1.10"
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Gate — every `Page` this package ships must serve the CANONICAL
5+
* `{ dialect, source }` envelope at every `ExpressionInputSchema` position
6+
* (#11480, extending #11255's platform-objects gate to this package).
7+
*
8+
* Both pages here are raw typed object literals reaching the kernel through
9+
* this plugin's own manifest bundles (`CLOUD_CONNECTION_UI_BUNDLE`,
10+
* `MARKETPLACE_INSTALLED_UI_BUNDLE`) — the same wire path as
11+
* `platform-objects`' pages, in files no `*.page.ts` sweep ever looked at.
12+
* They author ZERO expression keys today, which is exactly why the gate is
13+
* worth having: the hazard is the NEXT predicate added to one of them, which
14+
* would ship bare with every authoring-time signal green.
15+
*
16+
* The detector lives in `@objectstack/lint` (`page-envelope-audit.ts` — its
17+
* header carries the hazard and the three-door design; its own test file
18+
* carries the negative controls). What this file owns is this package's
19+
* POPULATION: the export-shape scan over `src/`, the per-page door
20+
* preconditions, the verdict, and a downgrade control proving the detector
21+
* reaches these real exports.
22+
*
23+
* ## The two exempted component types
24+
*
25+
* `cloud-connection:panel` and `marketplace:installed-list` are
26+
* console-registered widgets with no `ComponentPropsMap` row, so door 3 has
27+
* no schema to read their `properties` with. The exemption is asserted
28+
* EXACTLY (a new unmapped type reds), and it is valid only while those
29+
* components author an EMPTY props bag — nothing authored is nothing to
30+
* serve bare. The moment either widget grows a real authored prop, the
31+
* emptiness assert reds and forces the decision: give the type a
32+
* `ComponentPropsMap` row, or widen the exemption knowingly.
33+
*/
34+
35+
import { readFileSync, readdirSync } from 'node:fs';
36+
import { dirname, join } from 'node:path';
37+
import { fileURLToPath } from 'node:url';
38+
import { describe, expect, it } from 'vitest';
39+
import type { Page } from '@objectstack/spec/ui';
40+
import {
41+
auditPageExpressionEnvelopes,
42+
renderBareExpressionFindings,
43+
walkPageComponents,
44+
} from '@objectstack/lint';
45+
import { CloudConnectionSettingsPage } from './cloud-connection-ui.js';
46+
import { MarketplaceInstalledPage } from './marketplace-ui.js';
47+
48+
type AnyRec = Record<string, unknown>;
49+
50+
/** This file lives in `src/`, so the scan root IS the package's `src/`. */
51+
const HERE = dirname(fileURLToPath(import.meta.url));
52+
53+
// ───────────────────────────────────────────────────────────────────────────
54+
// The population this gate covers
55+
// ───────────────────────────────────────────────────────────────────────────
56+
57+
/**
58+
* Every page this package ships, audited by export name — with the unmapped
59+
* component types each page is EXPECTED to report (the exemptions above).
60+
*/
61+
const AUDITED_PAGES: { exportName: string; page: Page; exemptUnmappedTypes: string[] }[] = [
62+
{
63+
exportName: 'CloudConnectionSettingsPage',
64+
page: CloudConnectionSettingsPage,
65+
exemptUnmappedTypes: ['cloud-connection:panel'],
66+
},
67+
{
68+
exportName: 'MarketplaceInstalledPage',
69+
page: MarketplaceInstalledPage,
70+
exemptUnmappedTypes: ['marketplace:installed-list'],
71+
},
72+
];
73+
74+
function pageLabel(exportName: string, page: Page): string {
75+
const name = typeof (page as AnyRec).name === 'string' ? (page as AnyRec).name : '(unnamed)';
76+
return `${exportName} (${String(name)})`;
77+
}
78+
79+
function tsFilesUnder(dir: string, out: string[] = []): string[] {
80+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
81+
const full = join(dir, entry.name);
82+
if (entry.isDirectory()) {
83+
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
84+
tsFilesUnder(full, out);
85+
} else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) {
86+
out.push(full);
87+
}
88+
}
89+
return out;
90+
}
91+
92+
/** Strip comments so a `: Page =` inside prose is not read as a declaration. */
93+
function stripComments(source: string): string {
94+
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
95+
}
96+
97+
/**
98+
* Every `export const X: Page = …` declared anywhere in this package's `src/`.
99+
*
100+
* Discovery is by EXPORT SHAPE, never by filename — the sweep that first
101+
* recorded this defect class looked at `*.page.ts` and therefore missed this
102+
* package's pages entirely (they live in `*-ui.ts` files). Scanning source
103+
* text rather than a barrel is what makes "a page nobody covered" visible.
104+
*/
105+
function declaredPageExports(): { name: string; file: string }[] {
106+
const out: { name: string; file: string }[] = [];
107+
for (const file of tsFilesUnder(HERE)) {
108+
const source = stripComments(readFileSync(file, 'utf8'));
109+
for (const match of source.matchAll(/export\s+const\s+(\w+)\s*:\s*Page\s*=/g)) {
110+
out.push({ name: match[1]!, file: file.slice(HERE.length + 1) });
111+
}
112+
}
113+
return out.sort((a, b) => a.name.localeCompare(b.name));
114+
}
115+
116+
const AUDITS = AUDITED_PAGES.map(({ exportName, page, exemptUnmappedTypes }) => ({
117+
exportName,
118+
page,
119+
exemptUnmappedTypes,
120+
audit: auditPageExpressionEnvelopes(page, pageLabel(exportName, page)),
121+
}));
122+
123+
// ───────────────────────────────────────────────────────────────────────────
124+
// The gate
125+
// ───────────────────────────────────────────────────────────────────────────
126+
127+
describe('cloud-connection Page exports serve canonical expression envelopes', () => {
128+
it('covers every `Page` declared in this package — and audits nothing undeclared', () => {
129+
const declared = declaredPageExports();
130+
const audited = new Set(AUDITED_PAGES.map(p => p.exportName));
131+
const uncovered = declared.filter(d => !audited.has(d.name));
132+
expect(
133+
uncovered.map(d => `${d.name} (${d.file})`).join('\n'),
134+
'a raw-literal `Page` in this package is not audited by this gate. Add it to '
135+
+ 'AUDITED_PAGES above (or, if it is deliberately unshipped, say so here).',
136+
).toBe('');
137+
138+
// Both directions: a page audited here but invisible to the export-shape
139+
// scan means its declaration lost the `: Page` annotation — the exact way
140+
// MarketplaceInstalledPage shipped un-discoverable before #11480.
141+
const declaredNames = new Set(declared.map(d => d.name));
142+
const undeclared = AUDITED_PAGES.filter(p => !declaredNames.has(p.exportName));
143+
expect(
144+
undeclared.map(p => p.exportName).join('\n'),
145+
'this page is audited but not discovered by the `export const X: Page =` scan — '
146+
+ 'restore the `: Page` annotation on its declaration so the NEXT page authored '
147+
+ 'beside it is discoverable too.',
148+
).toBe('');
149+
150+
// Population floor: the gate is worthless if it silently reads nothing.
151+
expect(AUDITED_PAGES.length).toBeGreaterThanOrEqual(2);
152+
expect(declared.length).toBeGreaterThanOrEqual(2);
153+
});
154+
155+
it.each(AUDITS)('$exportName parses through PageSchema (door 1 precondition)', ({ audit }) => {
156+
expect(
157+
audit.pageParseError ?? '',
158+
'door 1 cannot run: this page does not parse, so every schema-typed expression '
159+
+ 'position on it is unread by this gate.',
160+
).toBe('');
161+
});
162+
163+
it.each(AUDITS)('$exportName: every component parses through PageComponentSchema (door 2 precondition)', ({ audit }) => {
164+
expect(
165+
audit.componentParseErrors.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'),
166+
'door 2 cannot run for these components: they do not parse, so their expression '
167+
+ 'positions are unread by this gate.',
168+
).toBe('');
169+
expect(audit.componentCount).toBeGreaterThan(0);
170+
});
171+
172+
it.each(AUDITS)('$exportName: unmapped component types are EXACTLY the recorded exemptions (door 3 precondition)', ({ audit, exemptUnmappedTypes }) => {
173+
// See the module header for why these two types are exempt. Anything else
174+
// unmapped is a new door-3 blind spot: declare the props schema in
175+
// `ComponentPropsMap`, or record the exemption here with the reason.
176+
expect(audit.unmappedTypes.map(e => e.type).sort()).toEqual([...exemptUnmappedTypes].sort());
177+
});
178+
179+
it.each(AUDITS)('$exportName: every exempted component authors an EMPTY props bag', ({ page, exemptUnmappedTypes }) => {
180+
// The exemption above is only sound while there is nothing authored for
181+
// door 3 to miss. A real key landing in one of these bags must force a
182+
// decision (props schema row, or a conscious wider exemption) — not ride
183+
// through a standing exemption silently.
184+
const offenders = walkPageComponents(page as AnyRec, '')
185+
.filter(w => typeof w.component.type === 'string' && exemptUnmappedTypes.includes(w.component.type))
186+
.filter(w => {
187+
const props = w.component.properties;
188+
return !!props && typeof props === 'object' && Object.keys(props).length > 0;
189+
})
190+
.map(w => `${w.path} [${String(w.component.type)}]`);
191+
expect(offenders.join('\n')).toBe('');
192+
});
193+
194+
it.each(AUDITS)('$exportName: every authored `properties` bag parses against its props schema (door 3 precondition)', ({ audit }) => {
195+
expect(
196+
audit.unreadableProps.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'),
197+
'door 3 cannot run for these components: their authored `properties` are refused by '
198+
+ 'the declared props schema, so a props-level expression key there is unread by '
199+
+ 'this gate.',
200+
).toBe('');
201+
});
202+
203+
it.each(AUDITS)('$exportName authors NO bare expression string', ({ audit }) => {
204+
expect(renderBareExpressionFindings(audit.findings)).toBe('');
205+
});
206+
});
207+
208+
// ───────────────────────────────────────────────────────────────────────────
209+
// Downgrade control — the imported detector reaches this package's REAL pages
210+
// ───────────────────────────────────────────────────────────────────────────
211+
212+
const BARE = 'has(record.status) && record.status == "bound"';
213+
214+
describe('downgrade control — a shipped page, bare predicate injected', () => {
215+
it('flags CloudConnectionSettingsPage the moment a bare predicate lands on its panel', () => {
216+
// Deep-cloned — the export itself is untouched (the pristine re-audit
217+
// below proves it). The injected position is the panel component's
218+
// `visibleWhen`, i.e. the exact next-predicate the card names as the
219+
// hazard for this page.
220+
const source = JSON.parse(JSON.stringify(CloudConnectionSettingsPage)) as AnyRec;
221+
const regions = source.regions as AnyRec[];
222+
const panel = (regions[1]!.components as AnyRec[])[0]!;
223+
expect(panel.type).toBe('cloud-connection:panel');
224+
panel.visibleWhen = BARE;
225+
226+
const audit = auditPageExpressionEnvelopes(source, pageLabel('CloudConnectionSettingsPage', source as unknown as Page));
227+
expect(audit.findings.map(f => f.path)).toEqual(['regions[1].components[0].visibleWhen']);
228+
const rendered = renderBareExpressionFindings(audit.findings);
229+
expect(rendered).toContain('cloud_connection_settings');
230+
expect(rendered).toContain('regions[1].components[0].visibleWhen');
231+
expect(rendered).toContain('authored BARE');
232+
233+
const pristine = auditPageExpressionEnvelopes(
234+
CloudConnectionSettingsPage,
235+
pageLabel('CloudConnectionSettingsPage', CloudConnectionSettingsPage),
236+
);
237+
expect(renderBareExpressionFindings(pristine.findings)).toBe('');
238+
});
239+
240+
it('flags MarketplaceInstalledPage the same way — the page the `: Page` scan used to miss', () => {
241+
const source = JSON.parse(JSON.stringify(MarketplaceInstalledPage)) as AnyRec;
242+
const regions = source.regions as AnyRec[];
243+
const list = (regions[1]!.components as AnyRec[])[0]!;
244+
expect(list.type).toBe('marketplace:installed-list');
245+
list.visibleWhen = BARE;
246+
247+
const audit = auditPageExpressionEnvelopes(source, pageLabel('MarketplaceInstalledPage', source as unknown as Page));
248+
expect(audit.findings.map(f => f.path)).toEqual(['regions[1].components[0].visibleWhen']);
249+
const rendered = renderBareExpressionFindings(audit.findings);
250+
expect(rendered).toContain('marketplace_installed');
251+
252+
const pristine = auditPageExpressionEnvelopes(
253+
MarketplaceInstalledPage,
254+
pageLabel('MarketplaceInstalledPage', MarketplaceInstalledPage),
255+
);
256+
expect(renderBareExpressionFindings(pristine.findings)).toBe('');
257+
});
258+
});

packages/cloud-connection/src/marketplace-ui.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
* stages (Installed Apps first).
1818
*/
1919

20+
import type { Page } from '@objectstack/spec/ui';
21+
2022
/** "Browse Marketplace" — owned by the browse capability (the proxy). */
2123
export const MARKETPLACE_BROWSE_UI_BUNDLE = {
2224
id: 'com.objectstack.cloud-connection.marketplace-browse-ui',
@@ -40,8 +42,14 @@ export const MARKETPLACE_BROWSE_UI_BUNDLE = {
4042

4143
/** "Installed Apps" — owned by the local-install capability (ADR-0009 P2a:
4244
* the page itself is now metadata; the console provides only the
43-
* `marketplace:installed-list` widget). */
44-
export const MarketplaceInstalledPage = {
45+
* `marketplace:installed-list` widget).
46+
*
47+
* Declared `: Page` (#11480) — this is a raw-literal page served through the
48+
* bundle below, and `export const X: Page =` is the export shape the
49+
* canonical-envelope gate's population scan discovers pages by. Un-annotated
50+
* it shipped invisibly to that scan (measured while wiring this package's
51+
* `canonical-expression-envelopes.test.ts`). */
52+
export const MarketplaceInstalledPage: Page = {
4553
name: 'marketplace_installed',
4654
label: 'Installed Apps',
4755
type: 'app' as const,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { defineConfig } from 'vitest/config';
4+
import path from 'node:path';
5+
6+
export default defineConfig({
7+
resolve: {
8+
// One entry, for `canonical-expression-envelopes.test.ts` (#11480) — the
9+
// only suite here that imports `@objectstack/lint` as a VALUE. It runs the
10+
// shared canonical-envelope detector (`auditPageExpressionEnvelopes`) over
11+
// this package's own `Page` exports.
12+
//
13+
// Unaliased, that specifier resolves through `exports` to `lint/dist` — a
14+
// BUILD ARTIFACT — which would make the gate a verdict about build state
15+
// rather than about the source next to it (`pnpm check:test-source-alias`,
16+
// #7668/#7778). The loud failure (missing export) is the mild half; a dist
17+
// merely BEHIND lets the gate run GREEN against the detector's old
18+
// behaviour with nothing in the output saying so — and this suite's whole
19+
// purpose is to run the CURRENT detector over the CURRENT pages.
20+
//
21+
// Array form with an anchored pattern, deliberately, and here that is
22+
// load-bearing rather than stylistic: `@objectstack/lint` exports a second
23+
// subpath (`./runtime`), and the object form matches by PREFIX, so a bare
24+
// `@objectstack/lint` key with a FILE replacement would also swallow
25+
// `@objectstack/lint/runtime` and resolve it to `…/lint/src/index.ts/runtime`
26+
// — `ENOTDIR`, at run time, in a config that reads as correct. Same shape
27+
// as `packages/platform-objects`'s config (the reference consumer of this
28+
// detector), `packages/rest`'s (#7955) and `service-storage`'s (#7778).
29+
alias: [
30+
{
31+
find: /^@objectstack\/lint$/,
32+
replacement: path.resolve(__dirname, '../lint/src/index.ts'),
33+
},
34+
],
35+
},
36+
// No `test` block: this package had no vitest config until now, so its suite
37+
// ran on vitest's defaults. Leaving discovery untouched keeps this file's
38+
// only effect the alias above — narrowing `include` here would silently drop
39+
// the rest of the package's suite while this gate stayed green.
40+
});

packages/lint/src/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,3 +704,25 @@ export type {
704704
// package is exactly what the module exists to prevent (#5405).
705705
export { walkPageComponents, isSourceAuthoredPage } from './page-walk.js';
706706
export type { WalkedComponent } from './page-walk.js';
707+
708+
// The canonical-expression-envelope audit for raw-literal `Page` exports
709+
// (#11255 → #11480): a page authored as a typed object literal is never
710+
// PARSED, so every `ExpressionInputSchema` position on it reaches the wire
711+
// bare and the console silently routes it to its legacy evaluator. Built on
712+
// `walkPageComponents` above and exported for the same reason that walk is:
713+
// the detector's first home was package-local, which left every OTHER
714+
// published package's pages unreachable, and copying it per package is the
715+
// documented dead-rule failure mode. Each owning package runs a thin test
716+
// over its own `Page` exports; the detector's own behaviour is tested once,
717+
// in `page-envelope-audit.test.ts`. (`collectBare`, the single-door
718+
// primitive, is deliberately NOT re-exported — consumers always want the
719+
// three-door union.)
720+
export {
721+
auditPageExpressionEnvelopes,
722+
renderBareExpressionFindings,
723+
} from './page-envelope-audit.js';
724+
export type {
725+
BareExpressionFinding,
726+
EnvelopeAuditDoor,
727+
PageEnvelopeAudit,
728+
} from './page-envelope-audit.js';

0 commit comments

Comments
 (0)