Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/30608-remaining-field-indexes-extension-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/lint': minor
---

fix(lint)!: the remaining field indexes (form layout, notify email-field recipient, public form file field, flow templates, page visualization bindings, searchable fields, autonumber formats) judge an object as the registry serves it, and an object that declares no field keeps the list-view rule's skip once an extension adds to it (#30608)

Clause-②: yes (narrowing) — `os validate` / `os build` / `os lint` refuse shapes they accepted before, each a field an `objectExtensions` entry of the same artifact adds or re-declares, now judged on its served definition: a notify node's `recipients.emailField` naming an owner `email` field an extension re-declares as `text` is `notify-email-field-not-email` at `error`; a form open to anonymous intake whose section names an `image` / `file` field an extension adds, or an owner field an extension re-declares as one, is `public-form-file-field` at `error`; a `list` page whose `allowedVisualizations` leads with `gallery` over an owner `image` field an extension re-declares as `text` is `page/visualization-without-binding` at `error`; a `searchableFields` entry naming an owner text field an extension re-declares as `formula` is `searchable-field-unsearchable` at `error`; an autonumber field an extension adds whose format names a field the object lacks is `autonumber-references-unknown-field` at `error`, and an autonumber format naming an owner field declared `required: true` that an extension re-declares without it is `autonumber-references-optional-field` at `warning`; a flow template `{{ record.F.sub }}` through an owner text field an extension re-declares as a lookup is `flow-template-lookup-traversal` at `warning`. The widening half: a form section, a notify email-field recipient, a flow template hole, a gallery or calendar binding, a `searchableFields` entry (an object's own set, a list view's narrowing, a react page's `<ListView searchableFields>`) or an autonumber format naming a field an `objectExtensions` entry adds is no longer refused as unknown, and a list view over an object that declares no field is no longer refused once an extension adds to it.
Metadata-protocol: narrowed
Skills-docs: none — measured: no sentence in `skills/` or `content/docs/` states a declared-only reading of the moved rule ids

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: objects, object extensions, forms, flows, pages, views and autonumber formats keep their shapes and meanings. What moves is that a field an objectExtensions entry adds or re-declares is judged on the definition the registry serves. Each new refusal names a field whose served definition the runtime already refuses or misrenders (a text address, a file on an anonymous form, a gallery with no image field, a formula search column, an autonumber whose scope field may be blank or absent). There is no rewrite that keeps what the author meant; the author fixes the extension's definition or the reference, and the refusal names which. -->

**BREAKING** accept-set narrowing in `os validate` / `os build` / `os lint` (`@objectstack/lint`), for multi-package artifacts whose bodies add fields to another package's object through `objectExtensions`.

- **Before.** Seven field indexes read an object's declared `fields` only: the form-layout rule (`form-field-unknown`), the notify email-field recipient rule, the public form file-field rule, the flow template rule, the page visualization binding rule, the searchable-fields index (`indexObjectSearchTargets`, behind both an object's or list view's `searchableFields` and a react page's `<ListView searchableFields>`) and the autonumber format lint. A field an `objectExtensions` entry adds was unknown to them although the runtime serves it, and a field an extension re-declares was judged on the owner's definition. The list-view rule, since it reads the served graph, judged an object that declares no field as having only the extension's fields.
- **Now.** Each reads the shared fold in `object-graph.ts` (`objectsAsServed`): the declared fields plus each field an `objectExtensions` entry adds, an extension's re-declared field replacing the owner's definition whole, as the registry's `mergeObjectDefinitions` does. A name no body declares or adds keeps the same refusal, rule id, severity and message. These findings move (union artifact and per-package leg alike):
- an extension-added field named by a form section, a notify email-field recipient (typed `email`), a flow template hole, a `searchableFields` entry or an autonumber format (declared `required: true`): its unknown-field verdict (`form-field-unknown` `warning`, `notify-email-field-unknown` `error`, `flow-template-unknown-field` `warning`, `searchable-field-unknown` `error`, `autonumber-references-unknown-field` `error`) → nothing; a `gallery` / `calendar` binding derives from it, so `page/visualization-without-binding` → nothing;
- the same field typed otherwise: a notify recipient typed `text` → `notify-email-field-not-email` `error` (was `notify-email-field-unknown` `error`); an autonumber format naming it not `required` → `autonumber-references-optional-field` `warning` (was `autonumber-references-unknown-field` `error`); on a public form, an `image` / `file` / `avatar` / `video` / `audio` field → `public-form-file-field` `error` (was unjudged);
- an owner field an extension re-declares: judged on the extension's type, `required`, or autonumber format (the rows the Clause-② line names), and the inverse re-declarations pass (an owner `file` field re-declared `text` on a public form, an owner lookup re-declared `text` in a template, an owner autonumber re-declared `text`);
- an autonumber field an extension adds is checked as an owner's is;
- a list view over an object that declares no field: `list-view-field-unknown` `error` → nothing, extended or not.
- a flow variable bound to a record whose name is a field an `objectExtensions` entry adds to the trigger object: the record-triggered run flattens that field to top level too, so the variable is an ambiguous root and its holes are no longer judged (`flow-template-unknown-field` `warning` → nothing), as for a variable named after a declared trigger field;
- a form section on an extended object whose owner declares `fields` in the legacy array shape: the owner's array fields are now known names, so `form-field-unknown` `warning` on them → nothing.
- **Not judged.** A field two packages write at the same deciding priority is a served name, but no rule judges its type, `required` or autonumber format: which definition wins is registration order (a gallery page over an object carrying one is not reported). An object that declares no field of its own stays unjudged by the notify, public form, page visualization, searchable and autonumber rules and by the list-view rule, extended or not (an introspected schema). The form-layout and flow template rules judged such an object against the empty set before and still do, an extension's own names now known.
- **Not changed.** Which extension entries the fold keeps, the runtime, the object-level reads (`external`, the injected anchors, an object's own list views and `searchableFields`), and the finding paths. A stack that names no extension field reads as before. A flow variable whose name no body declares or adds on the trigger object is judged as before. A form section on an object nothing extends reads only map-shaped `fields`, as before: its legacy array fields are still reported `form-field-unknown`.

**What an author does.** Fix the extension's field definition (type an email recipient `email`, keep a file field off a public form, give a gallery an image field, keep a search column stored, mark an autonumber scope field `required: true`) or the reference that names it.
37 changes: 31 additions & 6 deletions packages/lint/src/lint-autonumber-formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,26 @@
* a `format` shorthand the runtime then rendered and threw on. A field that
* declares neither key is checked against the contract default, which is what
* it mints with.
*
* [#30608] Each object is read as SERVED (`objectsAsServed`, the package's one
* fold): the engine mints against the registry's merged object, so a format
* may interpolate a field an `objectExtensions` entry of the same artifact
* adds, an autonumber field an extension adds is minted and so checked, and an
* owner's field an extension re-declares is read on the extension's
* definition, which replaces the owner's whole (the registry's
* `mergeObjectDefinitions`): an autonumber re-declared as `text` mints
* nothing, and a referenced field re-declared without `required` is optional.
* Whether a field is an autonumber, its format and whether it is required are
* its definition, so a field two packages tie on is never walked as an
* autonumber, and a format naming one is reported neither unknown (the name is
* served) nor optional (which definition wins is registration order). An
* object that declares no field (an introspected schema) is skipped, extended
* or not: its other columns are not visible here, so a format naming one would
* read as unknown.
*/

import { parseAutonumberFormat, referencedFields, resolveAutonumberFormat } from '@objectstack/spec/data';
import { recordsOf } from './object-graph.js';
import { objectsAsServed, objectsWithoutTiedFields, recordsOf } from './object-graph.js';

export interface AutonumberLintFinding {
where: string;
Expand All @@ -53,14 +69,22 @@ export const AUTONUMBER_LITERAL_TOKEN = 'autonumber-unrecognized-token';
*/
export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] {
const findings: AutonumberLintFinding[] = [];
for (const obj of recordsOf(stack.objects)) {
const declared = recordsOf(stack.objects);
const servedFold = objectsAsServed(stack);
const served = objectsWithoutTiedFields(servedFold);
declared.forEach((obj, i) => {
const objectName = typeof obj.name === 'string' ? obj.name : '(unnamed object)';
const fields = recordsOf(obj.fields);
// name → required?, for schema-aware reference checks.
const fieldMeta = new Map<string, { required: boolean }>();
// [#30608] Skipped when it declares no field, extended or not (module note).
if (!recordsOf(obj.fields).some((f) => typeof f.name === 'string')) return;
const fields = recordsOf(served[i].fields);
const tiedKeys = typeof obj.name === 'string' ? servedFold.tied.get(obj.name) : undefined;
// name → required?, for schema-aware reference checks. `null` is a field
// two packages tie on: served, of no stated definition.
const fieldMeta = new Map<string, { required: boolean } | null>();
for (const f of fields) {
if (typeof f.name === 'string') fieldMeta.set(f.name, { required: f.required === true });
}
for (const key of tiedKeys ?? []) fieldMeta.set(key, null);

for (const f of fields) {
if (f.type !== 'autonumber') continue;
Expand Down Expand Up @@ -106,6 +130,7 @@ export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] {
continue;
}
const meta = fieldMeta.get(ref);
if (meta === null) continue;
if (!meta) {
findings.push({
where,
Expand All @@ -125,6 +150,6 @@ export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] {
}
}
}
}
});
return findings;
}
Loading
Loading