diff --git a/.changeset/visibility-predicate-family-runtime-publish.md b/.changeset/visibility-predicate-family-runtime-publish.md new file mode 100644 index 0000000000..e522a775fd --- /dev/null +++ b/.changeset/visibility-predicate-family-runtime-publish.md @@ -0,0 +1,45 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): the `views[]` visibility-predicate family now gates runtime `view` publishes (#7220) + +The four rules that judge a `views[]` conditional-visibility predicate ran on the +three CLI commands only. A `view` written through the runtime publish door — +Studio's designer, REST `/meta`, MCP — was judged by **none** of them, and that +door is the only one most tenants have and the one AI authors use. A predicate +that cannot parse saved clean and then failed OPEN in the console: the element +renders unconditionally, pixel-identical to one carrying no predicate at all +(#5149). + +Both registry entries move to `surfaces: ['cli', 'runtime-publish']` with +`runtimeTypes: ['view']`, in one edit. A publish of a `view` is now refused with +`422 invalid_metadata` when a predicate on it: + +- is not valid CEL (`visibility-predicate-syntax` — `===` instead of `==`); +- is valid CEL but overruns a parse bound (`visibility-predicate-over-budget`); +- names a bare identifier no binding root resolves (`visibility-bare-identifier` + — `status` instead of `record.status`); +- names a path the target schema does not declare, or a schema key without its + root (`predicate-path-unresolved` / `predicate-path-unrooted`, schema-bound + forms only). + +A mis-layered binding root (`visibility-root-mislayered`) is `warning` on every +surface and does not block: it rides back on the 2xx save response under +`advisories` (#4717), which is what let this move happen at all — running rules +and discarding their verdicts is the shape #4463 exists to close. + +**They move together on purpose.** The rule for predicate paths had its solo +wiring implemented and then reverted, because a `view` refused for an +unresolvable path while a predicate that does not parse at all walks through the +same door is less predictable than refusing neither. `authoring-rule-wiring.test.ts` +now pins the family property directly — all of this surface's ids are gated at the +runtime door, or none is — so the halves cannot drift apart again. + +Nothing changes for `os validate` / `os build` / `os lint`: the runtime door and +the CLI reach identical verdicts (same id, severity and path) on every input, and +a valid `view` still publishes with an empty `advisories` set. + +Writes that carry one of the defects above and used to succeed will now be +refused. The findings name the site and the fix, `OS_ALLOW_UNLINTED_METADATA_WRITES=1` +remains the migration hatch, and drafts are never gated — only a publish is. diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 076124390a..90c4cccffd 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -391,21 +391,32 @@ one. `sys_metadata` overlay rows are not in any config file, so there is no | View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | — | | Flow authoring anti-patterns (#1874) | ✓ | ✓ | ✓ | ✓ᶠ | | Flow trigger readiness — a flow that looks armed and never launches (#5762) | ✓ | ✓ | ✓ | ✓ᶠ | +| `views[]` conditional-visibility predicates — CEL syntax, parse budget, bare identifiers, binding-root layer, schema path refs (ADR-0089 D3b, #7010) | ✓ | ✓ | ✓ | ✓ᵛ | | Advisory: record titles, semantic field pointers (ADR-0085), seed replay/state safety, capability references, liveness | ✓ | ✓ | ✓ | — | | Package docs — flatness, prefixes, links (ADR-0046) | ✓ | ✓ | ✓ | — | | Undeclared authoring keys — every metadata collection (#3786) and the stack's own top-level keys (#4167) | ✓ | ✓ | — | — | | Naming, labels, data-model conventions, i18n coverage | — | — | ✓ | — | | Emits `dist/objectstack.json` | — | ✓ | — | — | -**`✓ᶠ` means the rule runs at that door for `flow` writes.** `flow` is the only -metadata type any rule declares there today — #4463 shipped P1 as one type and -four rule families, and widening is a data edit nobody has made yet. So an -object, view, page or dashboard save is checked by the schema parse and by +**`✓ᶠ` means the rule runs at that door for `flow` writes; `✓ᵛ` for `view` +writes.** Those are the two metadata types rules declare there today — #4463 +shipped P1 as one type and four rule families, and #7220 added the second type +by moving the whole `views[]` visibility-predicate family across in one edit. So +an object, page or dashboard save is still checked by the schema parse and by nothing else, and the `—` cells above are `—` for two different reasons: some rules read a stack-wide collection a one-item write does not carry (pages, dashboards, navigation, permission sets), and some parse authored source through `typescript`, which the kernel boot path must never load. +The visibility family crossed **together**, and that is the point rather than an +implementation detail. An earlier attempt wired one of its rules alone, which +would have refused a `view` whose predicate names an unresolvable path while a +predicate that does not parse *at all* saved clean through the same door — +sibling verdicts about one predicate, one enforced, none predictable. A rule +family is a wall, and a half-wired wall is worse than an unwired one, so +`authoring-rule-wiring.test.ts` pins the property directly: every rule on that +surface is gated at this door, or none is. + Both halves of a gate's behaviour are on this door. Gating findings **refuse** the write, as the same `422 invalid_metadata` envelope a schema failure produces — `issues[]` carrying `rule`, `path`, `where`, `message` and `hint`, so diff --git a/packages/lint/src/authoring-rule-wiring.test.ts b/packages/lint/src/authoring-rule-wiring.test.ts index 687894f9b6..ca4f38022a 100644 --- a/packages/lint/src/authoring-rule-wiring.test.ts +++ b/packages/lint/src/authoring-rule-wiring.test.ts @@ -448,6 +448,55 @@ describe('authoring-rule registry wiring (#4409)', () => { // A type nobody gated returns nothing rather than everything. expect(runtimeAuthoringRulesFor('translation')).toEqual([]); }); + + // ── #7220: a rule FAMILY crosses this wall together, or not at all ── + // + // The guard above makes each rule answer the surface question. It cannot + // catch the failure #7220 names, because every individual answer is + // well-formed: a family whose members split across the wall passes every + // assertion in this file and still ships a door where one verdict about a + // predicate is enforced and its siblings are not. #7214's implementer built + // exactly that state, measured it, and reverted it. + // + // So the family is pinned by NAME here. This is deliberately a stronger + // ratchet than a `surfaceReason` string: a reason is prose that goes stale + // silently, while this fails the moment someone moves one of the two entries + // and not the other — in either direction. + it('the `views[]` visibility-predicate family sits on ONE side of the wall', () => { + const FAMILY = ['validateVisibilityPredicates', 'validatePredicatePathRefs']; + + const entries = FAMILY.map((name) => { + const entry = AUTHORING_RULES.find((r) => r.name === name); + expect(entry, `${name} left AUTHORING_RULES — re-point this pin or retire it`).toBeDefined(); + return entry!; + }); + + const wired = entries.filter((e) => e.surfaces.includes('runtime-publish')); + expect( + wired.length === 0 || wired.length === entries.length, + `the views[] visibility-predicate family is SPLIT across the runtime publish gate: ` + + `${wired.map((e) => e.name).join(', ') || '(none)'} run at the door and ` + + `${entries.filter((e) => !e.surfaces.includes('runtime-publish')).map((e) => e.name).join(', ')} ` + + `do not. All of these rules judge the SAME predicate on the SAME surface, so an author ` + + `whose view is refused for one defect and waved through for a sibling defect cannot ` + + `predict the door. Move them together (#7220's ruling) or not at all.`, + ).toBe(true); + + // Same discipline on the type axis: two family rules gating different + // metadata types is the same split wearing a different hat. + const typeSets = new Set(entries.map((e) => [...(e.runtimeTypes ?? [])].sort().join(','))); + expect( + typeSets.size, + `the family's members declare different runtimeTypes (${[...typeSets].join(' vs ')}) — ` + + `a view write would reach some of them and not others`, + ).toBe(1); + + // Non-vacuous, and the record of where #7220 left this: both are wired, + // for `view`. Flipping the family back to CLI-only is a legal edit that + // must go through this line rather than around it. + expect(wired.map((e) => e.name)).toEqual(FAMILY); + expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual(FAMILY); + }); }); it('every rule declares a source file that exists', () => { diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 9a0e921e0c..3610efe0b2 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -351,32 +351,6 @@ const RUNTIME_HEAVY_SOURCE_PARSE = * wrong 422 there is the whole product, so P1 does not gate them — the issue's * own worked example, and every acceptance criterion on it, is a flow. */ -/** - * The rule judges a `views[]` conditional-visibility predicate — and every OTHER - * rule on that surface (the three ADR-0089 D3b rules in - * `validate-visibility-predicates.ts`) is CLI-only. - * - * This reason is deliberately NOT one of the three above: none of them is true - * here. `validatePredicatePathRefs` needs nothing but the written item — its - * oracle is the static `getMetadataTypeSchema` registry, not the tenant's other - * metadata — so the per-write snapshot IS enough, `stackKeyForType('view')` - * already exists, and wiring it would work today. - * - * It is not wired because a HALF-wired wall is worse than an unwired one. A - * Studio `view` write would then be refused for an unresolvable predicate PATH - * while a predicate that does not parse at all (`visibility-predicate-syntax`) - * and one with no root at all (`visibility-bare-identifier`) walked straight - * through the same door — three sibling verdicts about one predicate, one of - * them enforced, and no author able to predict which. The surface should move to - * `runtime-publish` as a FAMILY, in one measured edit, which is a decision about - * `views` writes rather than a rider on #7010's corpus-counted gate. - */ -const RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY = - 'Deliberate, and not a snapshot limitation: this rule needs only the written item, but every other ' - + 'rule on the `views[]` visibility-predicate surface (validate-visibility-predicates.ts) is CLI-only. ' - + 'Gating one of three sibling verdicts about the same predicate at the Studio door is less ' - + 'predictable than gating none; move the family together, as one measured edit.'; - const RUNTIME_OBJECT_WRITES_P2 = 'P2 (#4463): judges an object/field declaration. Object writes are the hottest metadata path in ' + 'the product, so P1 gates `flow` first and widens once the gate has real traffic behind it.'; @@ -841,14 +815,45 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // what decides whether any given diagnostic gates, exactly as `lintFlowPatterns` // has worked since #3760. The promotion follows the #5762 precedent: a family // that gains an `error` finding moves its registry tier in the same edit. + // + // ─── The `views[]` visibility-predicate FAMILY at the runtime door (#7220) ─── + // + // This entry and `validatePredicatePathRefs` below moved to `runtime-publish` + // in ONE edit, on the maintainer's 2026-08-10 ruling, sequenced after #4717's + // `advisories` channel landed (PR #7435). Before that move a `view` written + // through Studio / REST `/meta` / MCP — the only door most tenants have, and + // the door AI authors use — was judged by NONE of the family's six rule ids. + // + // They move together on purpose, and the two entries carry one comment because + // they are one wall: #7214's implementer wired its own rule here alone and then + // REVERTED it, because a `view` refused for an unresolvable predicate PATH + // while a predicate that does not parse at all walks through the same door is + // less predictable than refusing neither. A half-wired wall is worse than an + // unwired one, so `authoring-rule-wiring.test.ts` now pins the family property + // directly: every id on this surface is gated at the runtime door, or none is. + // + // The previous `surfaceReason` on THIS entry was `RUNTIME_NEEDS_FULL_SNAPSHOT`, + // and re-measuring it at move time found it false: both rule functions read + // `stack.views` and `stack.pages` and NO other collection — never `objects` — + // so the per-write snapshot the gate builds is not partial for them, it is + // complete. (`pages` is simply absent on a `view` write, so the page half + // contributes zero findings to both differential passes rather than inventing + // any.) The reason was not describing this rule; it was the default a rule got + // when nobody measured, which is the #4409/#4463 defect one layer in. + // + // Runtime input tier: the gate hands the rules the body as persisted, without + // `normalizeStackInput`, so the ADR-0087 D2 alias fold does NOT run at this + // door. That costs the family nothing — `validateVisibilityPredicates` reads + // `visibleWhen ?? visibleOn ?? visibility` itself, canonical-first, precisely + // so a caller handing it a raw authored object still gets a verdict. { name: 'validateVisibilityPredicates', tier: 'gating', input: 'normalized', commands: ALL, source: 'packages/lint/src/validate-visibility-predicates.ts', - surfaces: CLI_ONLY, - surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT, + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['view'], run: (stack) => validateVisibilityPredicates(stack), }, // #7010 — the same predicate surface, one question further in. The three @@ -871,8 +876,11 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ input: 'normalized', commands: ALL, source: 'packages/lint/src/validate-predicate-path-refs.ts', - surfaces: CLI_ONLY, - surfaceReason: RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY, + // The second half of the #7220 family move — see the block above the + // `validateVisibilityPredicates` entry. This is the rule whose solo wiring + // was reverted; it is wired now because its siblings are. + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['view'], run: (stack) => validatePredicatePathRefs(stack), }, // #1874 — flow authoring anti-patterns. Advisory by default; a finding marked diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index e066c79124..1e24763bf7 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -14,6 +14,9 @@ import { runtimeGatedTypes, stackKeyForType, } from './runtime-gate.js'; +import type { AuthoringFinding } from './authoring-rules.js'; +import { validateVisibilityPredicates } from './validate-visibility-predicates.js'; +import { validatePredicatePathRefs } from './validate-predicate-path-refs.js'; /** The issue's measured example: an approval flow whose expression approver is broken CEL. */ const brokenApprovalFlow = { @@ -206,3 +209,207 @@ describe('runtime publish gate (#4463)', () => { ).not.toThrow(); }); }); + +// ──────────────────────────────────────────────────────────────────────────── +// #7220 — the `views[]` visibility-predicate FAMILY at the runtime door +// ──────────────────────────────────────────────────────────────────────────── +// +// The registry move is a two-word edit per entry, and a two-word edit that +// nobody drives is indistinguishable from a declaration nobody reads (#4449, +// and the reason `runtime-gate.test.ts` exists next to the wiring guard at all). +// These tests drive the REAL dispatch path — `runRuntimeAuthoringRules`, the +// same function `packages/metadata-protocol/src/runtime-authoring-gate.ts` +// calls — with `view` bodies a Studio / REST `/meta` / MCP author could save, +// and assert the door now answers. +// +// The card this closes is specifically about the FAMILY, so the last test here +// is the one that matters most: it pins that no input exists on which one member +// fires and another does not. That asymmetry — a `view` refused for an +// unresolvable predicate PATH while a predicate that does not parse at all walks +// through — is exactly what #7214's implementer built, measured and reverted. + +/** A plain runtime view: `record` / `current_user` are the bound roots. */ +const runtimeView = (visibleWhen: string) => ({ + name: 'account_form', + label: 'Account', + object: 'account', + form: { sections: [{ label: 'Main', fields: [{ field: 'name', visibleWhen }] }] }, +}); + +/** + * A schema-bound metadata FORM published as a view — `data` is the bound root. + * `validatePredicatePathRefs` judges only this shape (`#7010`: the `record.*` + * layer's addressable path set is not closed), so the family's path half needs + * it to be non-vacuous. + */ +const schemaBoundForm = (visibleWhen: string) => ({ + name: 'field_editor', + label: 'Field editor', + form: { + data: { provider: 'schema', schemaId: 'field' }, + sections: [{ label: 'Main', fields: [{ field: 'name', visibleWhen }] }], + }, +}); + +/** A predicate that is flawless CEL and overruns `DEFAULT_LIMITS.maxAstNodes`. */ +const overBudget = Array.from({ length: 120 }, (_, i) => `record.name == 'n${i}'`).join(' || '); + +const gateView = (item: unknown) => + runRuntimeAuthoringRules({ type: 'view', item, context: { objects: [] } }); + +describe('the views[] visibility-predicate family at the runtime publish gate (#7220)', () => { + it('dispatches `view` writes to both family rules, from the shared table', () => { + expect(runtimeGatedTypes()).toContain('view'); + expect(stackKeyForType('view')).toBe('views'); + expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([ + 'validateVisibilityPredicates', + 'validatePredicatePathRefs', + ]); + }); + + // ── fires: the four defects the card names, at the door they bypassed ── + + it('REFUSES a predicate that does not parse as CEL', () => { + const { errors } = gateView(runtimeView("record.status === 'open'")); + const f = errors.find((e) => e.rule === 'visibility-predicate-syntax'); + expect( + f, + `\`===\` is not CEL. Before #7220 this exact body saved clean through Studio and then failed ` + + `OPEN in the console — the element renders unconditionally (#5149).`, + ).toBeDefined(); + // The 422 envelope's four keys: the caller turns `errors` into + // `err.issues` verbatim, so a finding without them is a refusal an author + // cannot act on. + expect(f!.severity).toBe('error'); + expect(f!.path).toBe('views[0].form.sections[0].fields[0]'); + expect(f!.where.length).toBeGreaterThan(0); + expect(f!.message).toMatch(/not valid CEL/); + expect(f!.hint).toMatch(/==/); + }); + + it('REFUSES a bare identifier no binding root resolves', () => { + const { errors } = gateView(runtimeView("status == 'open'")); + const f = errors.find((e) => e.rule === 'visibility-bare-identifier'); + expect(f, '`status` instead of `record.status` resolves nowhere on any layer').toBeDefined(); + expect(f!.severity).toBe('error'); + expect(f!.path).toBe('views[0].form.sections[0].fields[0]'); + expect(f!.hint).toMatch(/record\.status/); + }); + + it('REFUSES a predicate that overruns the CEL parse budget', () => { + const { errors } = gateView(runtimeView(overBudget)); + const f = errors.find((e) => e.rule === 'visibility-predicate-over-budget'); + expect(f, 'same refusal as a syntax fault, different edit (#7217)').toBeDefined(); + expect(f!.severity).toBe('error'); + }); + + it('REFUSES a predicate path the target schema does not declare', () => { + const { errors } = gateView(schemaBoundForm("data.tpye == 'text'")); + const f = errors.find((e) => e.rule === 'predicate-path-unresolved'); + expect( + f, + 'this is the rule whose SOLO wiring #7214 reverted — it is here because its siblings are', + ).toBeDefined(); + expect(f!.severity).toBe('error'); + expect(f!.path).toBe('views[0].form.sections[0].fields[0].visibleWhen'); + expect(f!.message).toMatch(/data\.tpye/); + }); + + it('REFUSES a schema key written without its binding root', () => { + const { errors } = gateView(schemaBoundForm("type == 'text'")); + expect(errors.map((e) => e.rule)).toContain('predicate-path-unrooted'); + }); + + it('reports a MISLAYERED root through the advisory channel, not a refusal', () => { + // The one family member that is `warning` on every surface, so it must not + // 422 — and must not be silent either. #4717's `advisories` channel (the + // ruling's sequencing precondition) is what makes the second half true: it + // rides back on the 2xx `saveMetaItem` response, so the Studio / MCP author + // this gate exists for can actually read it. That channel landing is why + // this move was allowed to happen at all rather than running rules and + // discarding their verdicts (#4463's own shape, one notch quieter). + const result = gateView(runtimeView("data.status == 'open'")); + expect(result.errors).toEqual([]); + const f = result.advisories.find((a) => a.rule === 'visibility-root-mislayered'); + expect(f, 'a wrong-layer paste must still reach the author').toBeDefined(); + expect(f!.severity).toBe('warning'); + }); + + // ── stays quiet ─────────────────────────────────────────────────────── + + it('a valid view still publishes, with the rules having RUN', () => { + const result = gateView(runtimeView("record.status == 'open'")); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]); + // "clean" and "nothing ran" must stay distinguishable. + expect(result.rulesRun).toEqual([ + 'validateVisibilityPredicates', + 'validatePredicatePathRefs', + ]); + }); + + it('does not blame a `view` write for a pre-existing violation elsewhere', () => { + // The D4 subtraction, on the newly gated type: a stored object carrying a + // broken predicate is not this write's to answer for. + const result = runRuntimeAuthoringRules({ + type: 'view', + item: runtimeView("record.status == 'open'"), + context: { + objects: [ + { name: 'account', fields: { name: { type: 'text', visibleWhen: 'broken ===' } } }, + ], + }, + }); + expect(result.errors).toEqual([]); + }); + + // ── the family property the ruling is about ─────────────────────────── + + it('the runtime door and `os build` reach the SAME verdict on every family input', () => { + // The asymmetry #7220 exists to prevent, pinned as a property rather than a + // count: for every input below, the finding set at the runtime gate is + // IDENTICAL — id, severity and path — to the finding set the two rule + // functions produce on the CLI. So there is no input on which one member of + // the family fires at the door and another does not, and no input on which + // the door and `os build` disagree about the same predicate. + // + // Asserted by set equality, not by "both are non-empty": a half-wired wall + // is precisely the state where both sides are non-empty and disagree. + const corpus = [ + runtimeView("record.status === 'open'"), // syntax + runtimeView("status == 'open'"), // bare identifier + runtimeView("data.status == 'open'"), // mislayered root + runtimeView(overBudget), // over budget + runtimeView("record.status == 'open'"), // clean + schemaBoundForm("data.tpye == 'text'"), // unresolvable path + schemaBoundForm("type == 'text'"), // unrooted schema key + schemaBoundForm("data.type == 'text'"), // resolvable path + ]; + + const fingerprints = (fs: readonly AuthoringFinding[]) => + fs.map((f) => `${f.severity}:${f.rule}@${f.path}`).sort(); + + let sawError = false; + let sawAdvisory = false; + + for (const item of corpus) { + const gate = gateView(item); + sawError ||= gate.errors.length > 0; + sawAdvisory ||= gate.advisories.length > 0; + + const stack = { views: [item] }; + const cli = [...validateVisibilityPredicates(stack), ...validatePredicatePathRefs(stack)]; + + expect( + fingerprints([...gate.errors, ...gate.advisories]), + `the runtime gate and os build disagree about ${JSON.stringify(item)} — one family member ` + + `is enforced at a door the others are not, which is the partial-enforcement surface ` + + `#7220 was filed to prevent`, + ).toEqual(fingerprints(cli)); + } + + // Non-vacuous: the corpus really does exercise both halves of the verdict. + expect(sawError, 'the corpus must contain a refusal').toBe(true); + expect(sawAdvisory, 'the corpus must contain an advisory').toBe(true); + }); +}); diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index d4b8c00ac9..577376b50a 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -267,11 +267,15 @@ describe('registry wiring', () => { expect(entry, '#7010 rule missing from AUTHORING_RULES').toBeDefined(); expect(entry!.tier).toBe('gating'); expect([...entry!.commands].sort()).toEqual(['build', 'lint', 'validate']); - // CLI-only, with a reason that is a DECISION rather than a limitation: see - // `RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY`. The whole `views[]` visibility - // family sits on this side of the wall, and it should move together. - expect([...entry!.surfaces]).toEqual(['cli']); - expect(entry!.surfaceReason).toBeTruthy(); + // #7220 — the family moved to the runtime publish gate TOGETHER, so this + // rule is no longer CLI-only and carries no `surfaceReason`. It is wired + // here because its siblings are: the solo wiring this rule's own PR + // implemented was reverted, and `authoring-rule-wiring.test.ts` now pins the + // family property (all of the surface's ids, or none) rather than trusting a + // prose reason to keep the halves in step. + expect([...entry!.surfaces]).toEqual(['cli', 'runtime-publish']); + expect([...entry!.runtimeTypes!]).toEqual(['view']); + expect(entry!.surfaceReason).toBeUndefined(); }); });