diff --git a/.changeset/lint-visibility-prose-names-the-surface.md b/.changeset/lint-visibility-prose-names-the-surface.md new file mode 100644 index 0000000000..96c5f2ad0d --- /dev/null +++ b/.changeset/lint-visibility-prose-names-the-surface.md @@ -0,0 +1,9 @@ +--- +'@objectstack/lint': patch +--- + +`visibility-root-mislayered` and `visibility-bare-identifier` now explain the metadata-editing layer by naming the **surface** — a schema-bound metadata-editing form, the row under edit — instead of a `*.form.ts` filename. + +Since the layer derivation landed (#7815), a form view declaring `data: { provider: 'schema', schemaId }` is judged at the metadata layer at the runtime publish gate. That door's audience is a Studio / REST `/meta` / MCP author who has no `*.form.ts` to open, so the prose justified a correct prescription by pointing at a file the reader cannot reach. The mirror (runtime) arm named `*.view.ts` / `*.page.ts` the same way and is fixed with it. + +Prose only: no rule id, severity, prescribed root or firing condition changes, and the actionable half of every message and hint is unchanged. The `*.form.ts` mentions addressed to a **file-aware caller** of `validateVisibilityPredicates` (the `opts.layer` contract) are deliberately kept — there the filename is accurate and is the point. diff --git a/packages/lint/src/validate-visibility-predicates.test.ts b/packages/lint/src/validate-visibility-predicates.test.ts index bbcf1dcbf1..b4aee41842 100644 --- a/packages/lint/src/validate-visibility-predicates.test.ts +++ b/packages/lint/src/validate-visibility-predicates.test.ts @@ -1,5 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { describe, it, expect } from 'vitest'; import { validateVisibilityPredicates, @@ -1136,3 +1140,127 @@ describe('visibility-predicate-over-budget (#7217)', () => { expect(findings[0].where).toBe('page "p"'); }); }); + +// ───────────────────────────────────────────────────────────────────── +// #8042 — the emitted prose names the SURFACE, never one authoring route's +// filename. +// +// Since #7815 the metadata layer is reachable at the runtime publish gate +// (`authoring-rules.ts` → `validateVisibilityPredicates(stack)`, no options): +// a form view declaring `data: { provider: 'schema', schemaId }` is judged +// there on its own say-so. That door's audience is a Studio / REST `/meta` / +// MCP author who has no `*.form.ts` to open, so a message that justified the +// layer by naming that file prescribed correctly and then explained itself +// somewhere the reader cannot go. +// +// Every case below calls with NO options, which IS the gate's call shape. +// Nothing here judges severity, ids or firing conditions — those are pinned +// above and by `runtime-gate.test.ts`, and #8042 changed none of them. +// ───────────────────────────────────────────────────────────────────── +describe('emitted prose names the surface, not a source file (#8042)', () => { + /** A schema-bound metadata-editing form — the shape the publish gate meets. */ + const publishedForm = (predicate: string) => ({ + views: [{ + name: 'field_editor', + data: { provider: 'schema', schemaId: 'field' }, + sections: [{ fields: [{ field: 'notes', visibleWhen: predicate }] }], + }], + }); + + /** Any `*.view.ts` / `*.page.ts` / `*.form.ts` spelling, in any position. */ + const SOURCE_FILENAME = /\.(form|view|page)\.ts/; + + it('the mis-layered advisory explains the metadata layer without naming a file', () => { + const f = validateVisibilityPredicates(publishedForm("record.type == 'grid'")) + .find((x) => x.rule === VISIBILITY_ROOT_MISLAYERED); + // Paired first: a silence would satisfy every `not.toContain` below. + expect(f, 'the advisory must still fire on a `record.`-rooted schema-bound form').toBeDefined(); + expect(f!.message).not.toMatch(SOURCE_FILENAME); + expect(f!.hint).not.toMatch(SOURCE_FILENAME); + // The prescription is byte-for-byte what it was: same root, same severity. + expect(f!.severity).toBe('warning'); + expect(f!.message).toContain('metadata-editing form'); + expect(f!.hint).toBe( + 'Metadata-editing forms bind `data` (the row under edit). Use e.g. ' + + "`data.type == 'grid'` instead of `record.type == 'grid'`.", + ); + }); + + it("the bare-identifier hint still prescribes `data.`, and still names no file", () => { + const f = bareFindings(publishedForm('status == active'))[0]; + expect(f, 'the refusal must still fire on a bare word in the LEFT position').toBeDefined(); + expect(f.severity).toBe('error'); + expect(f.hint).toContain('`data.status`'); + expect(f.hint).not.toMatch(SOURCE_FILENAME); + }); + + it('the RUNTIME arm is file-free too — the mirror sentence carried the same defect', () => { + // A view or page published through Studio has no `*.view.ts` either, and + // this arm explained the forbidden `data.` root by naming `*.form.ts`. Same + // class, other direction; fixing only the graded arm would have left half + // the sentence pointing at a file. + const f = validateVisibilityPredicates(formStack("data.type == 'grid'"))[0]; + expect(f.rule).toBe(VISIBILITY_ROOT_MISLAYERED); + expect(f.severity).toBe('warning'); + expect(f.message).not.toMatch(SOURCE_FILENAME); + expect(f.message).toContain('`data.`'); + // Untouched — the runtime hint was already written surface-first. + expect(f.hint).toContain("`record.status == 'open'`"); + }); + + it('no finding this module emits names a source file — the whole family, both layers', () => { + // The mechanical pin. One case per rule id per layer, so a future edit that + // reintroduces a filename in ANY arm goes red here rather than at a + // tenant's publish door. + const cases: Array<[string, Record]> = [ + ['mislayered · runtime', formStack("data.type == 'grid'")], + ['mislayered · metadata', publishedForm("record.type == 'grid'")], + ['bare-identifier · runtime', formStack("status == 'active'")], + ['bare-identifier · metadata', publishedForm("status == 'active'")], + ['syntax · runtime', formStack('country === "USA"')], + ['syntax · metadata', publishedForm('country === "USA"')], + ['over-budget · runtime', formStack(OVER_AST_NODES)], + ['over-budget · metadata', publishedForm(OVER_AST_NODES)], + ]; + for (const [name, stack] of cases) { + const findings = validateVisibilityPredicates(stack); + // Non-vacuous: every row must actually produce the finding it is named for. + expect(findings.length, `${name} produced nothing — the case has gone blind`).toBeGreaterThan(0); + for (const f of findings) { + expect(f.message, `${name} message`).not.toMatch(SOURCE_FILENAME); + expect(f.hint, `${name} hint`).not.toMatch(SOURCE_FILENAME); + } + } + }); + + it('the file-aware caller reads the SAME prose — this changed strings, not a code path', () => { + // `MISLAYER_BY_LAYER` is keyed by LAYER, not by how the layer was decided, + // so a caller passing `opts.layer = 'metadata'` (the file-aware caller, + // linting a real `*.form.ts`) reads exactly what the published-through- + // Studio author reads. Pinned because the tempting shape of this fix — fork + // the wording by caller and give the file-aware one its filename back — is + // two messages for one condition, which #5240 rules out. + const viaOption = validateVisibilityPredicates(formStack("record.type == 'grid'"), { layer: 'metadata' }) + .find((x) => x.rule === VISIBILITY_ROOT_MISLAYERED); + const viaDerivation = validateVisibilityPredicates(publishedForm("record.type == 'grid'")) + .find((x) => x.rule === VISIBILITY_ROOT_MISLAYERED); + expect(viaOption, 'the file-aware path must still reach the metadata arm').toBeDefined(); + expect(viaDerivation).toBeDefined(); + expect(viaOption!.message).toBe(viaDerivation!.message); + expect(viaOption!.hint).toBe(viaDerivation!.hint); + }); + + it('the file-aware CALLER contract keeps its filename — this was not a find-and-replace', () => { + // The discrimination that IS the card. What was wrong is prose addressed to + // an AUTHOR who may hold no file; `opts.layer` is addressed to a CALLER that + // is linting one, where `*.form.ts` is both accurate and the useful word. A + // blanket strip would have deleted that too, and would pass every assertion + // above — this is the one that notices. + const source = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), 'validate-visibility-predicates.ts'), + 'utf8', + ); + expect(source).toContain('a file-aware caller linting a `*.form.ts` does'); + expect(source).toContain('when linting a `*.form.ts` metadata-editing form'); + }); +}); diff --git a/packages/lint/src/validate-visibility-predicates.ts b/packages/lint/src/validate-visibility-predicates.ts index 7d5aee07fc..8d0479c6f5 100644 --- a/packages/lint/src/validate-visibility-predicates.ts +++ b/packages/lint/src/validate-visibility-predicates.ts @@ -76,8 +76,14 @@ * - **runtime** view/page surfaces (`*.view.ts` / `*.page.ts`) bind * `record` + `current_user` (pages also expose `page.`), so a `data.`-rooted * predicate here is a wrong-layer paste that silently never matches; and - * - **metadata-editing** forms (`*.form.ts` — the row under edit) bind `data`, so - * a `record.`-rooted predicate there is the same bug in the other direction. + * - **metadata-editing** forms — the row under edit — bind `data`, so a + * `record.`-rooted predicate there is the same bug in the other direction. + * That surface is reached two ways: a `*.form.ts` module, and a form view + * declaring `data: { provider: 'schema', schemaId }`, which is judged here on + * its own say-so whatever the caller passes (#7815). Only the first has a + * file, which is why the EMITTED prose names the surface and never a filename + * (#8042) — the `*.form.ts` mentions that remain below are the ones addressed + * to a file-aware CALLER of this function, where the filename is the point. * The layer is read off the METADATA where the metadata declares it, and taken * from the caller (`opts.layer`, default `'runtime'`) only where it does not — * see §Which layer a site is on (#7815). @@ -349,7 +355,9 @@ export type VisibilitySeverity = 'error' | 'warning'; /** * Which binding environment the linted surface belongs to (ADR-0089 §Context): * - `runtime` — `*.view.ts` / `*.page.ts`; binds `record` + `current_user` (+ `page`). - * - `metadata` — `*.form.ts` metadata-editing forms; binds `data` (the row under edit). + * - `metadata` — metadata-editing forms (a `*.form.ts` module, or a form view + * declaring `data: { provider: 'schema', schemaId }`); binds `data` (the row + * under edit). */ export type VisibilityLayer = 'runtime' | 'metadata'; @@ -672,8 +680,9 @@ function firstBareIdentifier(source: string, literalRhs: boolean): string | null /** * The root an author on this layer should have written. Runtime view/page - * surfaces bind the live record as `record`; a `*.form.ts` metadata-editing - * form binds the row under edit as `data` (ADR-0089 D3, §Context). + * surfaces bind the live record as `record`; a metadata-editing form binds the + * row under edit as `data` (ADR-0089 D3, §Context) — whether that form was + * authored as a `*.form.ts` module or published as a schema-bound form view. */ const CANONICAL_ROOT_BY_LAYER: Record = { runtime: 'record', @@ -692,8 +701,8 @@ const MISLAYER_BY_LAYER: Record< runtime: { forbiddenRoot: 'data', message: - 'visibility predicate is rooted at `data.` — that is the ' + - 'metadata-editing-form root (a `*.form.ts` row under edit), not a runtime ' + + 'visibility predicate is rooted at `data.` — that is the root a ' + + 'metadata-editing form binds (the row under edit), not a runtime ' + 'surface. A runtime view/page predicate that binds `data.` never matches ' + 'and the element renders unconditionally (ADR-0089).', hint: @@ -704,10 +713,11 @@ const MISLAYER_BY_LAYER: Record< metadata: { forbiddenRoot: 'record', message: - 'visibility predicate is rooted at `record.` — that is the runtime ' + - 'record-surface root (a `*.view.ts` / `*.page.ts` live record), not a ' + - 'metadata-editing form. A `*.form.ts` predicate that binds `record.` never ' + - 'matches and the element renders unconditionally (ADR-0089).', + 'visibility predicate is rooted at `record.` — that is the root a ' + + 'runtime view/page surface binds (the live record), not the root a ' + + 'metadata-editing form binds. On a metadata-editing form — the row ' + + 'under edit — a `record.`-rooted predicate never matches and the ' + + 'element renders unconditionally (ADR-0089).', hint: 'Metadata-editing forms bind `data` (the row under edit). Use e.g. ' + "`data.type == 'grid'` instead of `record.type == 'grid'`.", @@ -854,7 +864,7 @@ function checkElement( `Write \`${root}.${bare}\` instead of \`${bare}\`` + (layer === 'runtime' ? ' (runtime view/page surfaces bind `record` + `current_user`; a page component also exposes page state as `page.`).' - : ' (a `*.form.ts` metadata-editing form binds the row under edit as `data`).'), + : ' (a metadata-editing form binds the row under edit as `data`).'), }); } }