Skip to content

Commit 31f0f27

Browse files
fix(lint)!: os build and the page save door refuse a page component props CEL slot over the platform parse bounds (#25002) (#25088)
Closes #25002 Clause-②: no (narrowing: a page component props CEL slot over the platform parse bounds is refused at `os build` and the page door; it ships now under the amended 「甲」, #6015 `6303865025`) ## What changes `page-predicate-over-budget` (`validatePagePredicateBounds`, `packages/lint/src/validate-page-predicate-bounds.ts`) reports at `error` instead of `warning`. In the same edit, its entry in `packages/lint/src/authoring-rules.ts` moves from the `advisory` tier to the `gating` tier. A page carrying a CEL slot inside a component's `properties` bag whose source overruns `DEFAULT_LIMITS` (`maxAstNodes` 256, `maxDepth`, `maxListElements`) now fails `os validate`, `os build` and `os lint`. An active write of that page, or the promotion of its draft, is refused at the page save door. This is the refusal half of #24953, re-timed to 18.x by triage (6305866852) under the amended 「甲」 (#6015 `6303865025`). - The message drops "A future major release refuses it." The docblock's "Why a warning" section becomes "Why an error" and cites the amended ruling. The registry entry's comment says why the tier is `gating`. - `PagePredicateBoundsFinding.severity` is typed `'error'`. - No runtime or door code moves: the door already splits findings by severity, `error` to the refusal and the rest to `advisories`. ## The door's code, measured The card says `422 VALIDATION_ERROR`. The page save door actually answers **`422 INVALID_METADATA`**, the author-time refusal envelope every runtime-gate refusal uses. The new pin asserts `{ code: 'INVALID_METADATA', status: 422 }`, the same as the #23388 and #24491 page blocks next to it. The changeset says `INVALID_METADATA`. ## Tests - `packages/lint/src/validate-page-predicate-bounds.test.ts`: the warning assertions are now error assertions. The finding is `severity: 'error'`, and each of `os validate` / `os build` / `os lint` reports it as the page's only error. The page door's `errors` channel carries the build's finding verbatim, with nothing on `advisories`. The tier is `gating`. The message no longer contains "future major". The within-bounds CONTROL is unchanged and green. - `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`: a new block, "runtime authoring gate on PAGE writes — a props-bag CEL slot over the parse bounds (#25002)", runs through the REAL `saveMetaItem` / `publishMetaItem`. It is inserted after the #24491 page block, not at the end of the file, which keeps it textually apart from open PR #25052's append. It pins: - (a) the card's ~390-node (78-term) `page:tabs` item `visibleWhen` answers `{ code: 'INVALID_METADATA', status: 422 }`, with one `page-predicate-over-budget` issue at `pages[0].regions[0].components[0].properties.items[1].visibleWhen` naming "`maxAstNodes` budget (platform limit 256)", and no active row is stored; - (a) the draft → active promotion is refused the same way; - (b) the door's finding equals `os build`'s, key by key; - (c) CONTROL: a within-bounds tab predicate saves, and the row lands. - `runtime-gate.page-visibility-predicate-writes.test.ts`: one comment now says "refusal" instead of "warning". The dispatch-order pin is unchanged. Evidence (run on head `53ae2b07d2`, after `pnpm --filter '@objectstack/lint...' build` and `pnpm --filter '@objectstack/metadata-protocol^...' build`): - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-page-predicate-bounds.test.ts src/runtime-gate.page-visibility-predicate-writes.test.ts src/authoring-rule-wiring.test.ts` → `Test Files 3 passed (3)`, `Tests 59 passed (59)`. - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.runtime-authoring-gate.test.ts` → `Test Files 1 passed (1)`, `Tests 180 passed (180)`. - `pnpm --filter @objectstack/lint typecheck` (tsc plus `check:test-typecheck`) → exit 0. - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` (the whole package) → `Test Files 153 passed (153)`, `Tests 7056 passed (7056)`. `pnpm --filter @objectstack/metadata-protocol typecheck` → exit 0 (in the same `&&` chain, lock `VERDICT command-exit 0`). ### Ablation (one-shot, nothing left in the tree) Committed first, then mutated through `scripts/ablation-replace.mjs` (anchor `severity: 'error',` above `rule: PAGE_PREDICATE_OVER_BUDGET,`, hit x1 -> x0, blob `e4a15923c57d` -> `51d908070c3a`) to the pre-change severity, spelled `['warning', 'ablation25002'][0] as 'error'` so the marker survives the bundle. `pnpm --filter @objectstack/lint build`, then `scripts/ablation-dist-preflight.mjs @objectstack/lint ablation25002` → marker present in 4 built files. - Mutant, lint: `validate-page-predicate-bounds.test.ts` → `Tests 5 failed | 13 passed (18)` (the error finding, `os validate` / `os build` / `os lint`, the door's errors channel). - Mutant, door: `protocol.runtime-authoring-gate.test.ts -t '#25002'` → `Tests 3 failed | 1 passed | 176 skipped` ((a) refusal, (a) draft promotion, (b) parity fail; (c) the within-bounds control stays green, as it must). - Restore: `git checkout HEAD -- PATH`, `git diff HEAD` empty, hash `e4a15923c57d` equals the HEAD blob; rebuilt; `ablation-dist-preflight --absent` → marker absent from all 20 built files, tree clean. Restored run: lint file `18 passed (18)`, door file `180 passed (180)`. ## Producers, measured before crossing 0 in-repo producers carry such a slot, so this PR fixes none: - **Page modules:** every `*.page.ts` and `pages/*.ts` module under `examples/` and `packages/` (30 files, 65 page values), loaded with tsx and judged by the built `validatePagePredicateBounds`, gives 0 findings. - **Composed stacks:** the `app-crm`, `app-multi-package` and `app-todo` stacks also give 0. The `app-showcase` config did not load here, because an unbuilt `@objectstack/connector-mcp` was missing; its pages are covered by the page-module pass. - **Literal scan:** every tracked `.ts/.tsx/.mts/.js/.mjs/.json/.yaml` file under `examples/`, `packages/` (dogfood and the package pages included), `skills/`, `apps/` and `content/docs/` was scanned (9,919 files). Each string literal or `+`-chain of 120 or more characters (13,840) was judged by `validateExpression`'s bounds verdict, and 0 are `cel-too-large`. - **HotCRM:** the same literal scan over `objectstack-ai/hotcrm` at `c8a7a70` (611 files) finds 0. - **Positive controls:** a planted 78-term predicate is found by both instruments. - **Residual:** a predicate assembled at run time (template substitution, `.join`) is outside the literal scan. The page-module pass covers it for the in-repo pages. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 66 commands at `53ae2b07d2`, and all 66 were run. 63 exit 0, including `check-adr-0087-registration --base origin/main` (it reads the `not-required (no-migration-prescription)` marker), `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests`, `check:nul-bytes`, `check:cross-package-test-inputs` and `check:test-source-alias`. Three are NOT MEASURED, each exiting 3 on its own prerequisite and not on a finding: - `check:dual-build-cjs-loads`: needs a whole-workspace `dist/`. - `check:lean-entry-closure`: needs a built `@objectstack/objectql`. - `check:type-check-debt`: its closure build hit the shared verify lock's queue budget. CI runs all three. `dispatch-gates --ran` with every exit code recorded: `66 derived, 63 run, 3 NOT-MEASURED, 0 UNRUN`. ## Acceptance notes - The pending changeset `.changeset/24953-page-predicate-bounds-warning.md` (from #24999, not yet released) still says "A future major release refuses it." If both ship in one release, the two CHANGELOG entries for `@objectstack/lint` disagree. This PR's changeset says it supersedes the warning-only behaviour. The other file is outside this claim's file surface, so it is not edited here. The seat can amend that one sentence in a follow-up if wanted. - The card's `VALIDATION_ERROR` is not what the door answers. See "The door's code, measured" above. - No generated docs reference this rule, and the CLI's rule count (`Running author-time rules (50)`) does not move. Nothing was regenerated. ## Release `@objectstack/lint` `minor`, BREAKING (FROM a warning with the page saved, TO an error with the page refused). The one-line fix: shrink the predicate, or precompute it into a formula or roll-up field. ADR-0087: `not-required (no-migration-prescription)`, following the shape of bb4f5cc. `content/docs/releases/` is untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1862d8d commit 31f0f27

6 files changed

Lines changed: 177 additions & 30 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: `os build`, `os validate`, `os lint` and the page save door refuse a page component props CEL slot over the platform's parse bounds (#25002)
6+
7+
Clause-②: no (narrowing: a page component props CEL slot over the platform parse bounds is refused at `os build` and the page door; it ships now under the amended 「甲」, #6015 `6303865025`)
8+
9+
A CEL source over `DEFAULT_LIMITS` (`maxAstNodes` 256, `maxDepth`, `maxListElements`) does not parse anywhere on the platform. It faults on every evaluation, and the console fails open, so the element it was written to gate draws for everyone. `page-predicate-over-budget` (`validatePagePredicateBounds`) finds such a source in the CEL slots declared inside a page component's `properties` bag: a `page:tabs` item `visibleWhen`, an `object-form` section or inline field `visibleWhen` / `readonlyWhen` / `requiredWhen`, `record:alert` `visible`, and the `visible` / `disabled` of the `action:*` blocks. The finding was a warning. It is now an error, and its authoring-rules entry moves from the advisory tier to the gating tier. This supersedes the warning-only behaviour described for the same rule earlier in this release line; its message no longer says "A future major release refuses it."
10+
11+
**BREAKING — what moves for consumers.**
12+
13+
- FROM: a warning at `os validate`, `os build` and `os lint`, and an entry on the page save door's `advisories` channel while the page saved. TO: an error. The three commands fail, and an active page write answers `422 INVALID_METADATA` with a `page-predicate-over-budget` issue located at the slot and naming the bound it overruns. The page is not stored. This covers `saveMetaItem` in publish mode (Studio, REST `/meta`, MCP) and the promotion of a draft (`publishMetaItem`).
14+
- The page door answers `INVALID_METADATA`, its existing author-time refusal envelope, not `VALIDATION_ERROR`.
15+
16+
**Fix (one line).** Shrink the predicate (collapse an `||` chain into one `in ['a', 'b', …]` test), or precompute the heavy part into a formula or roll-up field on the object and test that one field.
17+
18+
**Unchanged.**
19+
20+
- A within-bounds source draws nothing at any door.
21+
- The component node's own `visibleWhen` keeps its existing verdict (`visibility-predicate-over-budget`), and this rule does not report it twice.
22+
- Stored pages are not migrated and are not refused on read. A stored page with such a slot keeps loading and serving until it is next saved, and that save is judged. Saving a page as a draft is still allowed; publishing the draft is judged.
23+
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
24+
- No public export or signature moves; `PagePredicateBoundsFinding.severity` is now `'error'`.
25+
- Measured before crossing, through the built `@objectstack/lint`: 0 in-repo producers carry such a slot. Every page module under `examples/` and `packages/` (30 files, 65 page values) draws 0 findings, and so do the `app-crm`, `app-multi-package` and `app-todo` composed stacks. A literal scan of 9,919 tracked source files (13,840 string literals of 120+ characters) finds 0 sources over the bounds, and HotCRM at `c8a7a70` (611 files) finds 0. A planted 78-term control is found by both instruments.
26+
27+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at `os build`, `os validate`, `os lint` and the page save door of a page component props CEL slot whose source overruns the platform's parse bounds: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored page whose slot is refused keeps loading until it is next saved, and the repair is the author's rewrite of the predicate (shrinking it, or precomputing it into a formula or roll-up field), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1710,14 +1710,16 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
17101710
// `record:alert` / `action:*` `visible` and `disabled`), judged for the
17111711
// platform's parse bounds with `validateExpression`'s verdict. The component
17121712
// node's own `visibleWhen` is the visibility family's above, an error; these
1713-
// slots were judged by no rule at any door. `advisory` in 18.x: refusing a
1714-
// page that saves today is a narrowing no ADR-0087 conversion can carry, so
1715-
// under the standing ruling for post-GA narrowings (「甲」, record
1716-
// 6292005854) only the warning ships now and the refusal waits for the next
1717-
// major. Page-local, so the per-write snapshot is the whole input.
1713+
// slots were judged by no rule at any door. `gating` since #25002: the
1714+
// finding is an `error`, so a page carrying such a slot fails all three
1715+
// commands and its next save is refused at the page door. It first shipped
1716+
// as an advisory; the amended standing ruling for post-GA narrowings
1717+
// (「甲」, record 6303865025) ships a narrowing no ADR-0087 conversion can
1718+
// carry now, as a BREAKING minor. Page-local, so the per-write snapshot is
1719+
// the whole input.
17181720
{
17191721
name: 'validatePagePredicateBounds',
1720-
tier: 'advisory',
1722+
tier: 'gating',
17211723
input: 'normalized',
17221724
commands: ALL,
17231725
source: 'packages/lint/src/validate-page-predicate-bounds.ts',

‎packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ describe('#24491 — the page door gives the build\'s visibility-predicate verdi
127127
'validateComponentTypes',
128128
'validatePrintPageBlocks',
129129
...FAMILY,
130-
// #24953 — the props-bag CEL slots' bounds warning, after the family.
130+
// #24953 / #25002 — the props-bag CEL slots' bounds refusal, after the family.
131131
'validatePagePredicateBounds',
132132
]);
133133
});

‎packages/lint/src/validate-page-predicate-bounds.test.ts‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
/**
44
* #24953 — a CEL slot inside a page component's `properties` over the
55
* platform's parse bounds is reported, with `validateExpression`'s bounds
6-
* verdict, at every door that accepts a page; a WARNING in 18.x (「甲」).
6+
* verdict, at every door that accepts a page. #25002 — the finding is an
7+
* ERROR: `os validate` / `os build` / `os lint` fail and the page door refuses
8+
* the write (the amended 「甲」, record 6303865025).
79
*
810
* The card's shape is a `page:tabs` item `visibleWhen` of ~390 AST nodes,
911
* which parsed through `PageSchema` and drew no finding anywhere while the
1012
* console faulted on it at every evaluation and drew the tab for everyone.
13+
* The door's real save path (422, nothing stored) is pinned in
14+
* `@objectstack/metadata-protocol`'s `protocol.runtime-authoring-gate.test.ts`.
1115
*/
1216
import { validateExpression } from '@objectstack/formula';
1317
import { PageSchema } from '@objectstack/spec/ui';
@@ -66,38 +70,40 @@ describe('#24953 — the card\'s shape: a `page:tabs` item `visibleWhen` over `m
6670
const verdict = validateExpression('predicate', OVER_AST_NODES);
6771
expect(verdict.errors.map((e) => e.code)).toEqual(['cel-too-large']);
6872
expect(verdict.errors[0]!.params).toMatchObject({ limit: 'maxAstNodes', limitValue: 256 });
69-
// Parse acceptance is unchanged by this rule — it is a finding, not a refusal.
73+
// Parse acceptance is unchanged by this rule — the refusal is the lint finding's, not the schema's.
7074
expect(PageSchema.safeParse(tabsPage(OVER_AST_NODES)).success).toBe(true);
7175
});
7276

73-
it('is a WARNING naming the field and the bound, at the slot, without echoing the source', () => {
77+
it('is an ERROR naming the field and the bound, at the slot, without echoing the source', () => {
7478
const findings = validatePagePredicateBounds({ pages: [tabsPage(OVER_AST_NODES)] });
7579
expect(findings).toHaveLength(1);
7680
expect(findings[0]).toMatchObject({
77-
severity: 'warning',
81+
severity: 'error',
7882
rule: PAGE_PREDICATE_OVER_BUDGET,
7983
where: 'page "deal_home" · page:tabs',
8084
path: TAB_PATH,
8185
});
8286
expect(findings[0]!.message).toContain('`visibleWhen`');
8387
expect(findings[0]!.message).toContain('`maxAstNodes` budget (platform limit 256)');
8488
expect(findings[0]!.message).not.toContain('record.f0');
89+
// The 18.x warning's forward-looking sentence is gone: the refusal is now.
90+
expect(findings[0]!.message).not.toContain('future major');
8591
});
8692

8793
for (const command of ['validate', 'build', 'lint'] as const) {
88-
it(`\`os ${command}\` reports it as a warning, never an error`, () => {
94+
it(`\`os ${command}\` fails on it: an error, the only one the page draws`, () => {
8995
const stack = { pages: [tabsPage(OVER_AST_NODES)] };
9096
const findings = runAuthoringRules(command, { normalized: stack, parsed: stack });
91-
expect(ours(findings).map((f) => [f.severity, f.path])).toEqual([['warning', TAB_PATH]]);
92-
expect(findings.filter((f) => f.severity === 'error')).toEqual([]);
97+
expect(ours(findings).map((f) => [f.severity, f.path])).toEqual([['error', TAB_PATH]]);
98+
expect(findings.filter((f) => f.severity === 'error').map((f) => f.rule)).toEqual([PAGE_PREDICATE_OVER_BUDGET]);
9399
});
94100
}
95101

96-
it('the page publish door carries it on the advisories channel, the build\'s finding verbatim', () => {
102+
it('the page publish door refuses it on the errors channel, the build\'s finding verbatim', () => {
97103
const result = gatePage(tabsPage(OVER_AST_NODES));
98104
expect(result.rulesRun).toContain('validatePagePredicateBounds');
99-
expect(ours(result.errors)).toEqual([]);
100-
const atDoor = ours(result.advisories);
105+
expect(ours(result.advisories)).toEqual([]);
106+
const atDoor = ours(result.errors);
101107
expect(atDoor.map((f) => f.rule)).toEqual([PAGE_PREDICATE_OVER_BUDGET]);
102108
const stack = { pages: [tabsPage(OVER_AST_NODES)] };
103109
const atBuild = ours(runAuthoringRules('build', { normalized: stack, parsed: stack }));
@@ -186,9 +192,9 @@ describe('#24953 — no double report with the visibility family', () => {
186192
});
187193

188194
describe('#24953 — wiring', () => {
189-
it('runs on all three commands as an advisory rule, and at the page door only', () => {
195+
it('runs on all three commands as a gating rule, and at the page door only', () => {
190196
const entry = authoringRulesFor('validate').find((r) => r.name === 'validatePagePredicateBounds');
191-
expect(entry?.tier).toBe('advisory');
197+
expect(entry?.tier).toBe('gating');
192198
for (const command of ['build', 'lint'] as const) {
193199
expect(authoringRulesFor(command).map((r) => r.name)).toContain('validatePagePredicateBounds');
194200
}

‎packages/lint/src/validate-page-predicate-bounds.ts‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,17 @@
5858
* budget by definition, and `validateExpression`'s own `detail` quotes the
5959
* author's line); the `path` locates it.
6060
*
61-
* ## Why a warning
61+
* ## Why an error
6262
*
63-
* Refusing a page that saves today is a narrowing no ADR-0087 conversion can
64-
* carry — a predicate cannot be shrunk mechanically — so under the standing
65-
* ruling for breaking narrowings after the 18.0 GA (「甲」, record 6292005854)
66-
* the 18.x line ships the loud half only: a `warning` at `os validate`,
67-
* `os build`, `os lint` and the page publish door's `advisories` channel. The
68-
* refusal belongs to the next major, which promotes the finding to `error`.
63+
* Such a source never gates what it was written to gate, at any door, so the
64+
* finding is a refusal: `os validate`, `os build` and `os lint` fail, and the
65+
* page publish door answers its structured 422 on the page's next save.
66+
* Stored pages keep loading and serving — the gate judges writes, never the
67+
* read path. The 18.x line first shipped this as a warning; under the amended
68+
* standing ruling for breaking narrowings (「甲」, record 6303865025: a
69+
* narrowing no ADR-0087 conversion can carry also ships now, as a BREAKING
70+
* minor) the refusal ships in 18.x too. No conversion carries it: a predicate
71+
* cannot be shrunk mechanically, so the author's fix is the hint below.
6972
*/
7073

7174
import { validateExpression } from '@objectstack/formula';
@@ -78,7 +81,7 @@ import { recordsOf } from './object-graph.js';
7881
export const PAGE_PREDICATE_OVER_BUDGET = 'page-predicate-over-budget';
7982

8083
export interface PagePredicateBoundsFinding {
81-
severity: 'warning';
84+
severity: 'error';
8285
rule: typeof PAGE_PREDICATE_OVER_BUDGET;
8386
/** e.g. `page "pipeline_home" · page:tabs`. */
8487
where: string;
@@ -202,14 +205,13 @@ export function validatePagePredicateBounds(stack: AnyRec): PagePredicateBoundsF
202205
? `the \`${verdict.limit}\` budget (platform limit ${verdict.limitValue})`
203206
: "one of the platform's CEL parse budgets";
204207
findings.push({
205-
severity: 'warning',
208+
severity: 'error',
206209
rule: PAGE_PREDICATE_OVER_BUDGET,
207210
where: `page "${pageName}" · ${type}`,
208211
path: `${path}.properties${slot.rel}`,
209212
message:
210213
`\`${slot.key}\` is valid CEL that exceeds ${bound}, so it does not parse anywhere on the ` +
211-
'platform: it faults on every evaluation and never gates what it was written to gate. ' +
212-
'A future major release refuses it.',
214+
'platform: it faults on every evaluation and never gates what it was written to gate.',
213215
hint:
214216
"Make the expression smaller: collapse an `||` chain into one `in ['a', 'b', …]` test, or " +
215217
'precompute the heavy part into a formula or roll-up field on the object and test that one field',

‎packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3471,6 +3471,116 @@ describe('runtime authoring gate on PAGE writes — the component visibleWhen ve
34713471
});
34723472
});
34733473

3474+
// #25002 — a CEL slot INSIDE a page component's `properties` bag that overruns
3475+
// the platform's parse bounds (`page-predicate-over-budget`,
3476+
// `validatePagePredicateBounds`) is refused at the runtime save door, as
3477+
// `os build` refuses it. #24953 shipped it as an advisory (the door admitted the
3478+
// save and listed the finding); the amended 「甲」 (record 6303865025) ships
3479+
// the refusal in 18.x. The card's shape: a `page:tabs` item `visibleWhen` of
3480+
// ~390 AST nodes, over `maxAstNodes` (256). Pinned through the REAL
3481+
// `saveMetaItem`:
3482+
//
3483+
// (a) the active save is refused — 422 `INVALID_METADATA`, the door's own
3484+
// envelope — naming the slot and the bound, and nothing lands; the draft
3485+
// promotion is refused the same way;
3486+
// (b) the door's finding IS the build's;
3487+
// (c) control: a within-bounds tab predicate saves, and the row lands.
3488+
//
3489+
// ⚠️ As above: this package reaches `@objectstack/lint` through its built
3490+
// `dist/`, so a registry or rule edit is invisible here until
3491+
// `pnpm --filter @objectstack/lint build` has run.
3492+
describe('runtime authoring gate on PAGE writes — a props-bag CEL slot over the parse bounds (#25002)', () => {
3493+
const RULE = 'page-predicate-over-budget';
3494+
const TAB_PATH = 'pages[0].regions[0].components[0].properties.items[1].visibleWhen';
3495+
/** 78 `||` terms, five AST nodes each — the card's ~390-node class. */
3496+
const OVER = Array.from({ length: 78 }, (_, i) => `record.f${i} == ${i}`).join(' || ');
3497+
const WITHIN = "record.stage == 'won' || page.tab != ''";
3498+
const page = (tabVisibleWhen: string) => ({
3499+
name: 'deal_home',
3500+
label: 'Deal',
3501+
type: 'app',
3502+
regions: [{
3503+
name: 'main',
3504+
components: [{
3505+
type: 'page:tabs',
3506+
properties: {
3507+
items: [
3508+
{ label: 'Overview', value: 'overview', children: [] },
3509+
{ label: 'Finance', value: 'finance', visibleWhen: tabVisibleWhen, children: [] },
3510+
],
3511+
},
3512+
}],
3513+
}],
3514+
});
3515+
3516+
const savePage = (protocol: any, item: unknown, extra: Record<string, unknown> = {}) =>
3517+
protocol.saveMetaItem({ type: 'page', name: 'deal_home', item, ...extra });
3518+
const activePageRows = (rows: Map<string, Row>) =>
3519+
Array.from(rows.values()).filter((r) => r.type === 'page' && r.state === 'active');
3520+
3521+
let warn: ReturnType<typeof vi.spyOn>;
3522+
beforeEach(() => {
3523+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
3524+
delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES;
3525+
});
3526+
afterEach(() => {
3527+
warn.mockRestore();
3528+
delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES;
3529+
});
3530+
3531+
it('(a) REFUSES an active save of the card\'s tab-item `visibleWhen`, naming the bound; nothing stored', async () => {
3532+
const { protocol, rows } = makeProtocol();
3533+
3534+
const err = await savePage(protocol, page(OVER)).catch((e: any) => e);
3535+
3536+
expect(err, 'the save resolved — the door still admits the over-budget slot').toBeInstanceOf(Error);
3537+
expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
3538+
expect(err.rulesRun).toContain('validatePagePredicateBounds');
3539+
const issues = (err.issues ?? []).filter((i: any) => i.rule === RULE);
3540+
expect(issues, `issues: ${JSON.stringify(err.issues)}`).toHaveLength(1);
3541+
expect(issues[0]).toMatchObject({ severity: 'error', path: TAB_PATH });
3542+
expect(issues[0].message).toContain('`maxAstNodes` budget (platform limit 256)');
3543+
expect(activePageRows(rows)).toEqual([]);
3544+
});
3545+
3546+
it('(a) gates the draft → active promotion of the same page', async () => {
3547+
const { protocol, rows } = makeProtocol();
3548+
await expect(savePage(protocol, page(OVER), { mode: 'draft' })).resolves.toMatchObject({ success: true });
3549+
3550+
const err = await protocol.publishMetaItem({ type: 'page', name: 'deal_home' }).catch((e: any) => e);
3551+
3552+
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
3553+
expect((err.issues ?? []).map((i: any) => i.rule)).toContain(RULE);
3554+
expect(activePageRows(rows)).toEqual([]);
3555+
});
3556+
3557+
it('(b) the door and `os build` give the SAME finding', async () => {
3558+
const { protocol } = makeProtocol();
3559+
const err = await savePage(protocol, page(OVER)).catch((e: any) => e);
3560+
const atDoor = (err?.issues ?? []).filter((i: any) => i.rule === RULE);
3561+
3562+
const stack = { pages: [page(OVER)] };
3563+
const atBuild = runAuthoringRules('build', { normalized: stack, parsed: stack })
3564+
.filter((f) => f.severity === 'error');
3565+
3566+
expect(atBuild.map((f) => f.rule)).toEqual([RULE]);
3567+
expect(atDoor).toHaveLength(1);
3568+
for (const k of ['rule', 'where', 'path', 'message', 'hint'] as const) {
3569+
expect(atDoor[0][k], `door and build disagree on '${k}'`).toBe(atBuild[0]![k]);
3570+
}
3571+
});
3572+
3573+
it('(c) CONTROL — a within-bounds tab predicate saves, and the row lands', async () => {
3574+
const { protocol, rows } = makeProtocol();
3575+
3576+
await expect(savePage(protocol, page(WITHIN))).resolves.toMatchObject({ success: true });
3577+
3578+
expect(activePageRows(rows)).toHaveLength(1);
3579+
const stack = { pages: [page(WITHIN)] };
3580+
expect(runAuthoringRules('build', { normalized: stack, parsed: stack }).filter((f) => f.rule === RULE)).toEqual([]);
3581+
});
3582+
});
3583+
34743584
// A page component's `data` prop naming the retired view-level `api` provider,
34753585
// at the runtime save door. `object-grid`, `object-map`, `object-gantt` and
34763586
// `object-tree` take a view data source (`ViewDataSchema`) as `data`, so the

0 commit comments

Comments
 (0)