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
54 changes: 54 additions & 0 deletions .changeset/authoring-key-lint-full-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
"@objectstack/spec": minor
---

feat(spec): the unknown-authoring-key lint covers every metadata collection, not just objects (#3786)

#4148 introduced the lint for `object` and `field` — the two surfaces #4120
caught real drift on. Those two were a sample, not the population: of the
authorable metadata types, only a handful are `.strict()` (`flow` / `permission`
/ `position` / `tool` from #4001 Tier-A, joined mid-review by `app` via #4165).
Every other type strips an
undeclared key exactly the way `field` did — an author who misspells a key on a
`page`, an `agent` or a `dashboard` got the same parse-clean-value-gone silence,
with no lint watching.

`lintUnknownAuthoringKeys` now walks **every metadata collection** — 16 today:
object, page, dashboard, report, dataset, action, job, agent, skill, hook,
mapping, datasource, view, email_template, doc, book — and its coverage is
**derived, not listed**: which collections exist comes from `PLURAL_TO_SINGULAR`
(the same boundary map the normalizer uses), which schema judges each comes from
the canonical type→Zod registry, and whether linting is even meaningful is read
off each schema's own unknown-key posture. A third hand-written "types the lint
covers" list would have been the #3786 shape all over again, inside the tool
built to end it.

The posture rules keep the lint from ever disagreeing with the parse:

- **strip** (zod default) → lint: the parse drops unknown keys silently, and
that silence is what gets reported.
- **strict** → skip: the parse already rejects loudly with the schema's own
tombstone guidance; a second, possibly disagreeing voice helps nobody. This
bucket GROWS as #4001 tiers graduate schemas — `app` graduated (#4165) while
this change was in review, and the derivation adapted without an edit.
- **passthrough** → skip: unknown keys survive the parse, nothing is dropped.
- **unions** (`view`) → the union of member keys; lintable only when a member
strips and none passes unknowns through.

`defineStack`, `os validate` and `os build` pick the wider coverage up with no
code change of their own. Verified against the three first-party example apps
(28 pages, 29 flows, 11 actions and friends in the showcase): all clean, zero
false positives. Verified by mutation: dropping union handling, inverting the
strict filter, and skipping a collection each turn the tests red.

New root/kernel exports: `listLintableAuthoringCollections` (+
`LintableAuthoringCollection`) — the derived coverage as data, so tooling can
report what the evidence base for the #4001 strict tiers actually spans.

One import-site change: `lintUnknownAuthoringKeys` moved from the `/data`
subpath to the package root and `/kernel` (`@objectstack/spec` root import is
unchanged and remains the canonical site). Covering every type means importing
every schema, and `/data` is consumed by frontend bundles — the walker moving
out keeps that chunk from inheriting the whole schema universe. If you imported
it from `@objectstack/spec/data`, import from `@objectstack/spec` instead. The
comparator, guidance tables and finding types stay in `/data`, unchanged.
26 changes: 18 additions & 8 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,12 @@ literal (it comes from React state or a variable), a usage carrying a `{...sprea
a chart given static `data` (its columns are the author's own), and objects
another package defines.

### 9. Object and field keys the schema never declared
### 9. Authoring keys the schema never declared

`ObjectSchema` and `FieldSchema` are deliberately not strict, so a key they do
not declare **parses clean and is dropped** on the way to storage. Nothing fails;
Most metadata schemas are deliberately not strict — only the types the
ADR-0049 tier programme has hardened (`flow`, `permission`, `position`, `tool`,
`app`, …) reject unknown keys. On every other type a key the schema does not
declare **parses clean and is dropped** on the way to storage. Nothing fails;
the setting simply is not there.

```ts
Expand All @@ -238,11 +240,19 @@ Plain typos get a "did you mean" (`requred` → `required`); a retired key does
not, because the nearest declared key by spelling would be noise rather than
advice.

The check covers **every metadata collection** — pages, apps, agents,
dashboards, views, actions, and the rest — with its coverage derived from the
same collection map the loader uses, so a newly registered collection is
covered the moment it exists. Types that are already strict are skipped: there
the parse itself rejects loudly, with the schema's own guidance. Because the
lint reads each schema's real unknown-key posture, it can never disagree with
the parse.

This is **advisory** — the stack still loads. Strict rejection is where these
schemas are headed (ADR-0049 enforce-or-remove), but they are the two
most-authored surfaces in the protocol, so the tightening is scheduled on what
this check finds rather than assumed. `defineStack` reports the same findings at
config-load time, so an author sees them without running the CLI at all.
schemas are headed (ADR-0049 enforce-or-remove), but these are the protocol's
most-authored surfaces, so the tightening is scheduled on what this check finds
rather than assumed. `defineStack` reports the same findings at config-load
time, so an author sees them without running the CLI at all.

## The one gate, two entry points

Expand All @@ -263,7 +273,7 @@ config-load time, so an author sees them without running the CLI at all.
| View references — form targets, view-key collisions (#2554) | ✓ | ✓ |
| Flow authoring anti-patterns (#1874) | ✓ | ✓ |
| Liveness author-warnings | ✓ | ✓ |
| Undeclared object/field keys (#3786) | ✓ | ✓ |
| Undeclared authoring keys, every metadata collection (#3786) | ✓ | ✓ |
| Emits `dist/objectstack.json` | — | ✓ |

So `os validate` is the fast inner-loop check (no artifact); `os build` is what
Expand Down
8 changes: 7 additions & 1 deletion packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"F (const)",
"FIELD_KEY_GUIDANCE (const)",
"GUEST_POSITION (const)",
"LintableAuthoringCollection (interface)",
"MAP_SUPPORTED_FIELDS (const)",
"MEMBERSHIP_ROLE_ADMIN (const)",
"MEMBERSHIP_ROLE_DELEGATED_ADMIN (const)",
Expand Down Expand Up @@ -156,6 +157,7 @@
"isAggregatedViewContainer (function)",
"isKnownPlatformCapability (function)",
"lintUnknownAuthoringKeys (function)",
"listLintableAuthoringCollections (function)",
"mapMembershipRole (function)",
"normalizeMetadataCollection (function)",
"normalizePluginMetadata (function)",
Expand Down Expand Up @@ -601,10 +603,11 @@
"isKnownFilterToken (function)",
"isLegacyApiMethod (function)",
"isMultiValueField (function)",
"isPlainRecord (function)",
"isTenancyDisabled (function)",
"isTitleEligible (function)",
"isUniqueDeclared (function)",
"lintUnknownAuthoringKeys (function)",
"lintAuthoredRecordKeys (function)",
"missingFieldValues (function)",
"nextUtcCalendarDay (function)",
"objectForm (const)",
Expand Down Expand Up @@ -1517,6 +1520,7 @@
"KernelSecurityVulnerabilitySchema (const)",
"KernelShutdownEvent (type)",
"KernelShutdownEventSchema (const)",
"LintableAuthoringCollection (interface)",
"ListPackagesRequest (type)",
"ListPackagesRequestSchema (const)",
"ListPackagesResponse (type)",
Expand Down Expand Up @@ -1857,6 +1861,8 @@
"getMetadataTypeSchema (function)",
"isConsumerInstallable (function)",
"isKnownPlatformCapability (function)",
"lintUnknownAuthoringKeys (function)",
"listLintableAuthoringCollections (function)",
"listMetadataCreateSeedTypes (function)",
"listMetadataTypeSchemaTypes (function)",
"lowerRequiresFeature (function)",
Expand Down
127 changes: 40 additions & 87 deletions packages/spec/src/data/authoring-key-lint.test.ts
Original file line number Diff line number Diff line change
@@ -1,118 +1,71 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Tests for the unknown-authoring-key lint (#3786).
* Tests for the unknown-authoring-key CORE (#3786): the comparator and the
* curated guidance tables. The stack walker that applies them across every
* metadata collection is tested in `kernel/metadata-authoring-lint.test.ts`.
*
* Two jobs: prove the rule actually fires on the drifts that motivated it, and
* hold the guidance tables to the same non-rotting discipline the #4045 / #4040
* ledgers use — a `to` that names a key the schema no longer declares is advice
* pointing into a void, which is worse than no advice.
* The guidance tables are held to the #4045 / #4040 non-rotting discipline — a
* `to` that names a key the schema no longer declares is advice pointing into a
* void, which is worse than no advice.
*/

import { describe, it, expect } from 'vitest';

import {
lintUnknownAuthoringKeys,
lintAuthoredRecordKeys,
formatUnknownAuthoringKey,
FIELD_KEY_GUIDANCE,
OBJECT_KEY_GUIDANCE,
type UnknownAuthoringKeyFinding,
} from './authoring-key-lint';
import { ObjectSchema } from './object.zod';
import { FieldSchema } from './field.zod';

const shapeKeys = (s: unknown) => Object.keys((s as { shape: Record<string, unknown> }).shape);

/** A minimal well-formed stack with one object and one field. */
const stackWith = (obj: Record<string, unknown>, field: Record<string, unknown>) => ({
objects: [
{ name: 'crm_case', label: 'Case', fields: { owner: { label: 'Owner', type: 'text', ...field } }, ...obj },
],
});

describe('lintUnknownAuthoringKeys (#3786)', () => {
it('is silent on a clean stack', () => {
expect(lintUnknownAuthoringKeys(stackWith({}, {}))).toEqual([]);
});

it('reports a field key the schema does not declare', () => {
const [finding, ...rest] = lintUnknownAuthoringKeys(stackWith({}, { pii: true }));
expect(rest).toEqual([]);
expect(finding).toMatchObject({
path: 'objects.crm_case.fields.owner.pii',
surface: 'field',
key: 'pii',
});
// A retired key gets a prescription, not a "did you mean" into a void.
expect(finding.guidance).toBeTruthy();
expect(finding.suggestion).toBeUndefined();
function runComparator(
record: Record<string, unknown>,
declared: readonly string[],
guidance: Readonly<Record<string, { to?: string; why?: string }>> = {},
): UnknownAuthoringKeyFinding[] {
const out: UnknownAuthoringKeyFinding[] = [];
lintAuthoredRecordKeys(record, new Set(declared), guidance, 'field', 'p', out);
return out;
}

describe('lintAuthoredRecordKeys (#3786)', () => {
it('is silent when every key is declared', () => {
expect(runComparator({ a: 1, b: 2 }, ['a', 'b', 'c'])).toEqual([]);
});

it('reports an object key the schema does not declare, with the rename', () => {
const [finding] = lintUnknownAuthoringKeys(stackWith({ capabilities: { trackHistory: true } }, {}));
expect(finding).toMatchObject({
path: 'objects.crm_case.capabilities',
surface: 'object',
key: 'capabilities',
suggestion: 'enable',
});
it('reports an undeclared key with an edit-distance suggestion for a typo', () => {
const [f] = runComparator({ requred: true }, ['required', 'label']);
expect(f).toMatchObject({ path: 'p.requred', key: 'requred', suggestion: 'required' });
});

/**
* The exact set #4120 found rendering in `object.form.ts` while saving
* nothing. If the lint had existed, each of these would have been one warning
* rather than releases of a dead toggle.
*/
const FORM_DRIFT_KEYS = [
'indexed', 'immutable', 'filterable', 'placeholder', 'validation', 'errorMessage',
'audit', 'pii', 'encrypted', 'startingNumber',
'referenceFilter', 'cascadeDelete', 'formula', 'displayFormat', 'summaryType', 'summaryField',
] as const;

it.each(FORM_DRIFT_KEYS)('catches the #4120 drift key %s and says something actionable', (key) => {
const [finding, ...rest] = lintUnknownAuthoringKeys(stackWith({}, { [key]: 'x' }));
expect(finding, `${key} should be reported`).toBeDefined();
expect(rest).toEqual([]);
expect(finding.key).toBe(key);
// Either a rename target or a retirement reason — never a bare "unknown".
expect(
finding.suggestion ?? finding.guidance,
`${key} needs a rename target or a retirement reason`,
).toBeTruthy();
expect(formatUnknownAuthoringKey(finding)).toContain(key);
});

it('falls back to edit distance for a plain typo', () => {
const [finding] = lintUnknownAuthoringKeys(stackWith({}, { requred: true }));
expect(finding.suggestion).toBe('required');
it('a retirement suppresses the edit-distance fallback', () => {
// `pii` is 3 edits from `min` — "did you mean min?" reads as advice while
// being nonsense. A `why` with no `to` must yield guidance and NO suggestion.
const [f] = runComparator({ pii: true }, ['min', 'max'], FIELD_KEY_GUIDANCE);
expect(f.guidance).toBeTruthy();
expect(f.suggestion).toBeUndefined();
});

it('ignores the underscore-prefixed packaging channel', () => {
const findings = lintUnknownAuthoringKeys(
stackWith({ _packageId: 'p', _lock: true }, { _provenance: 'x' }),
);
expect(findings).toEqual([]);
it('a rename wins over edit distance', () => {
const [f] = runComparator({ capabilities: {} }, ['enable', 'label'], OBJECT_KEY_GUIDANCE);
expect(f.suggestion).toBe('enable');
});

it('reports every offending key, across objects and fields', () => {
const findings = lintUnknownAuthoringKeys({
objects: [
{ name: 'a', label: 'A', capabilities: {}, fields: { x: { type: 'text', pii: true } } },
{ name: 'b', label: 'B', fields: { y: { type: 'text', indexed: true } } },
],
});
expect(findings.map((f) => f.path).sort()).toEqual([
'objects.a.capabilities',
'objects.a.fields.x.pii',
'objects.b.fields.y.indexed',
]);
it('skips the underscore-prefixed packaging channel', () => {
expect(runComparator({ _packageId: 'p', _lock: true, _provenance: 'x' }, ['label'])).toEqual([]);
});

it('survives malformed input rather than throwing', () => {
// The lint runs before the parse, so it is handed whatever the author wrote.
for (const junk of [undefined, null, 42, 'x', {}, { objects: 'nope' }, { objects: [null, 7] }]) {
expect(() => lintUnknownAuthoringKeys(junk)).not.toThrow();
}
expect(lintUnknownAuthoringKeys({ objects: [{ name: 'a', fields: 'nope' }] })).toEqual([]);
it('formats guidance over suggestion over bare', () => {
const base = { path: 'p.k', surface: 'field', key: 'k' } as UnknownAuthoringKeyFinding;
expect(formatUnknownAuthoringKey({ ...base, guidance: 'gone.' })).toContain('gone.');
expect(formatUnknownAuthoringKey({ ...base, suggestion: 's' })).toContain("did you mean 's'");
expect(formatUnknownAuthoringKey(base)).toMatch(/dropped at load\.$/);
});
});

Expand Down
Loading
Loading