|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#17203] `app` is NOT an expression-scope root, and no prose face of the UI |
| 5 | + * schemas may say it is. |
| 6 | + * |
| 7 | + * ## The fact being pinned |
| 8 | + * |
| 9 | + * `@objectstack/formula`'s `SCOPE_ROOTS` has never declared `app`, and |
| 10 | + * ADR-0068 has never ruled it. Decision batch #67 (2026-09-07) ruled option B |
| 11 | + * — the engine's `SCOPE_ROOTS` is the contract and the renderer aligns to it — |
| 12 | + * and ObjectUI shipped that: `buildExpressionScope` no longer binds `app`. The |
| 13 | + * producer-side option-A card (widen `SCOPE_ROOTS` to match the old prose) was |
| 14 | + * closed `not_planned` in the same ruling. |
| 15 | + * |
| 16 | + * So every sentence in this package that told an author `app` is a root the |
| 17 | + * renderer mounts was describing a binding that no longer exists — and it was |
| 18 | + * the LAST surface anywhere that could still teach an author, or a |
| 19 | + * metadata-generating agent (ADR-0033 lists AI as a primary consumer of these |
| 20 | + * `.describe()` strings), to write `app.tier == 'pro'`. |
| 21 | + * |
| 22 | + * ## Why that mattered enough to pin |
| 23 | + * |
| 24 | + * The resulting predicate does not fail uniformly, and it is silent either |
| 25 | + * way: a field `visibleWhen` and a nav / area `visible` fail **OPEN** (the |
| 26 | + * gate stops hiding), while a conditional-formatting `condition` and a |
| 27 | + * row-action `visible` / `disabled` fail **CLOSED** (the rule silently stops |
| 28 | + * matching). An author sees nothing but a console line. |
| 29 | + * |
| 30 | + * ## The six faces |
| 31 | + * |
| 32 | + * Two of them are published — `.describe()` text reaches authoring tools and |
| 33 | + * is republished verbatim into `content/docs/references/ui/page.mdx` by |
| 34 | + * `build-docs.ts`. The other four are TSDoc, which no generator reads, so they |
| 35 | + * are seen only by whoever opens the file — often an AI author. That is |
| 36 | + * exactly why the first probe of this class missed some of them, and why the |
| 37 | + * pin covers both kinds. |
| 38 | + * |
| 39 | + * ⛔ **Scope: the claim, not the wording.** Rephrasing these sentences, |
| 40 | + * reordering the surviving roots, or documenting a root that genuinely gets |
| 41 | + * bound later is free. Re-introducing `app` into a scope-root enumeration on |
| 42 | + * any of these faces is not. |
| 43 | + * |
| 44 | + * ⛔ This file must NOT restate which roots `SCOPE_ROOTS` declares — that list |
| 45 | + * is `@objectstack/formula`'s, tested there. The assertions below are about |
| 46 | + * what these six sentences claim, which is a fact about this package's text. |
| 47 | + */ |
| 48 | + |
| 49 | +import fs from 'node:fs'; |
| 50 | +import path from 'node:path'; |
| 51 | +import url from 'node:url'; |
| 52 | + |
| 53 | +import { describe, it, expect } from 'vitest'; |
| 54 | + |
| 55 | +import { PageComponentSchema } from './page.zod'; |
| 56 | + |
| 57 | +const HERE = path.dirname(url.fileURLToPath(import.meta.url)); |
| 58 | +const read = (f: string) => fs.readFileSync(path.resolve(HERE, f), 'utf8'); |
| 59 | + |
| 60 | +const pageSource = read('page.zod.ts'); |
| 61 | +const actionSource = read('action.zod.ts'); |
| 62 | +const componentSource = read('component.zod.ts'); |
| 63 | + |
| 64 | +/** |
| 65 | + * The `app` token in a SCOPE-ROOT position — never the `app` metadata type, |
| 66 | + * which is a different word that legitimately appears all over these files |
| 67 | + * (`app` vs `utility` page types, `app.branding`, the `app` package type…). |
| 68 | + * |
| 69 | + * A bare /app/ search over any of these files matches dozens of those and is |
| 70 | + * therefore not a reading. Each assertion below is scoped to ONE sentence, |
| 71 | + * located by an anchor that survives rewording of everything around it. |
| 72 | + */ |
| 73 | +const sentenceContaining = (source: string, anchor: string): string => { |
| 74 | + const at = source.indexOf(anchor); |
| 75 | + expect(at, `anchor not found — the pin has drifted off its site: ${anchor}`).toBeGreaterThan(-1); |
| 76 | + // The docblock sentence: from the anchor to the next period that ends it. |
| 77 | + const tail = source.slice(at, at + 400); |
| 78 | + return tail.replace(/\n\s*\*\s?/g, ' '); |
| 79 | +}; |
| 80 | + |
| 81 | +/** Root tokens that are still true on these surfaces and must stay in place. */ |
| 82 | +const SURVIVING_ROOTS = ['features', 'os.user'] as const; |
| 83 | + |
| 84 | +describe('#17203 — no UI prose face advertises `app` as an expression-scope root', () => { |
| 85 | + describe('published faces (read by authoring tools and republished into the reference docs)', () => { |
| 86 | + it('`PageComponentSchema.visibleWhen`.describe() does not name `app` among the mounted roots', () => { |
| 87 | + // ⚠️ NOT `.shape` — ADR-0089 D3a made this schema a `.strict().transform(…)` |
| 88 | + // pipe (see `lazySchema`'s docblock), so it is a ZodPipe and the object |
| 89 | + // with the property descriptions is its INPUT side. Reaching for `.shape` |
| 90 | + // here yields `undefined` and every assertion below would then throw |
| 91 | + // rather than measure. |
| 92 | + const shape = (PageComponentSchema as unknown as { |
| 93 | + def: { in: { shape: Record<string, { description?: string }> } }; |
| 94 | + }).def.in.shape; |
| 95 | + const description = shape.visibleWhen.description; |
| 96 | + |
| 97 | + expect(description, 'the describe() must exist — this pin is about its content').toBeTruthy(); |
| 98 | + const mounts = description!.slice(description!.indexOf('additionally mounts')); |
| 99 | + |
| 100 | + // The claim: whatever this sentence says the renderer mounts, `app` is not in it. |
| 101 | + expect(mounts).not.toMatch(/`app`/); |
| 102 | + |
| 103 | + // Survival controls — deleting the token must not have taken the sentence with it. |
| 104 | + for (const root of SURVIVING_ROOTS) expect(mounts).toContain(root); |
| 105 | + expect(mounts).toContain('`data`'); |
| 106 | + expect(mounts).toContain('NOT contract-guaranteed'); |
| 107 | + |
| 108 | + // Contract-bound roots are a different clause and are untouched. |
| 109 | + expect(description).toContain('`record`'); |
| 110 | + expect(description).toContain('`current_user`'); |
| 111 | + }); |
| 112 | + }); |
| 113 | + |
| 114 | + describe('TSDoc faces (no generator reads these — an AI author opening the file does)', () => { |
| 115 | + it('page.zod.ts — the "Ambient roots" docblock', () => { |
| 116 | + const s = sentenceContaining(pageSource, 'The shipping renderer additionally mounts'); |
| 117 | + |
| 118 | + expect(s).not.toMatch(/`app`/); |
| 119 | + for (const root of SURVIVING_ROOTS) expect(s).toContain(root); |
| 120 | + expect(s).toContain('binds `data`'); |
| 121 | + }); |
| 122 | + |
| 123 | + it('action.zod.ts — the param-level `visible` scope list', () => { |
| 124 | + const s = sentenceContaining(actionSource, 'same scope as the action-level'); |
| 125 | + |
| 126 | + expect(s).not.toMatch(/`app`/); |
| 127 | + expect(s).toContain('`current_user`'); |
| 128 | + expect(s).toContain('`data`'); |
| 129 | + expect(s).toContain('`features`'); |
| 130 | + }); |
| 131 | + |
| 132 | + it('action.zod.ts — the action-level `visible` scope list, stated unbackticked', () => { |
| 133 | + // This face states the same claim in different words — `record/user/app/features`, |
| 134 | + // no backticks. A probe shaped for the backticked token could not see it. |
| 135 | + const s = sentenceContaining(actionSource, 'a predicate gates it per'); |
| 136 | + |
| 137 | + expect(s).not.toMatch(/\bapp\b/); |
| 138 | + expect(s).toContain('record/user/features'); |
| 139 | + }); |
| 140 | + |
| 141 | + it('component.zod.ts — the ambient-root name-resolution example', () => { |
| 142 | + const s = sentenceContaining(componentSource, 'so an ambient root ('); |
| 143 | + |
| 144 | + expect(s).not.toMatch(/`app`/); |
| 145 | + expect(s).toContain('`features`'); |
| 146 | + expect(s).toContain('`user`'); |
| 147 | + }); |
| 148 | + |
| 149 | + it('component.zod.ts — the page:tabs "also mounts the ambient …" sentence', () => { |
| 150 | + const s = sentenceContaining(componentSource, 'it also mounts the ambient'); |
| 151 | + |
| 152 | + expect(s).not.toMatch(/`app`/); |
| 153 | + for (const root of SURVIVING_ROOTS) expect(s).toContain(root); |
| 154 | + }); |
| 155 | + }); |
| 156 | + |
| 157 | + describe('probe controls — a zero above is only a reading if these hold', () => { |
| 158 | + /** |
| 159 | + * LIT. `page.zod.ts` still says `app` twice, both times about the page |
| 160 | + * TYPE (`app` vs `utility` vs `blank`) — a different word that this card |
| 161 | + * deliberately did NOT touch. It is the standing proof that a bare /app/ |
| 162 | + * probe over this file cannot answer the scope-root question, and that the |
| 163 | + * anchored, sentence-scoped assertions above are the required shape. |
| 164 | + * |
| 165 | + * If this ever reads 0, someone deleted the page-type prose and the |
| 166 | + * assertions above quietly became unable to distinguish a real regression |
| 167 | + * from a file that simply stopped using the word. |
| 168 | + */ |
| 169 | + it('LIT: the `app` page TYPE prose survives, so a scoped probe is still required', () => { |
| 170 | + expect(pageSource).toContain('`app` is an app-level page'); |
| 171 | + // THREE occurrences on TWO lines — `grep -c` answers lines and reads 2, |
| 172 | + // which is the whole reason this is asserted on occurrences instead. |
| 173 | + expect(pageSource.match(/`app`/g) ?? []).toHaveLength(3); |
| 174 | + }); |
| 175 | + |
| 176 | + /** |
| 177 | + * DARK. A fabricated token, which must read absent everywhere. It proves |
| 178 | + * the `not.toMatch` / `not.toContain` arms above are wired to something |
| 179 | + * that can actually fail, rather than passing on an empty haystack. |
| 180 | + */ |
| 181 | + it('DARK: a fabricated root token reads absent on every face', () => { |
| 182 | + for (const source of [pageSource, actionSource, componentSource]) { |
| 183 | + expect(source).not.toContain('`appzz_scope_root`'); |
| 184 | + } |
| 185 | + }); |
| 186 | + |
| 187 | + /** |
| 188 | + * The other half of the dark control: the helper must throw when its |
| 189 | + * anchor is gone, so a site that gets renamed out from under this pin |
| 190 | + * fails loudly instead of asserting over an empty string. |
| 191 | + */ |
| 192 | + it('DARK: a missing anchor fails the pin rather than passing vacuously', () => { |
| 193 | + expect(() => sentenceContaining(pageSource, 'no such anchor exists in this file')).toThrow(); |
| 194 | + }); |
| 195 | + }); |
| 196 | +}); |
0 commit comments