Skip to content

Commit f6472d7

Browse files
authored
feat(spec): the unknown-authoring-key lint covers every metadata collection (#3786) (#4178)
#4148's lint covered object and field — a sample, not the population: only a handful of authorable types are .strict(); every other type strips an undeclared key with no signal. The walker now covers every lintable collection (16 today), with its coverage DERIVED, not listed: collections from PLURAL_TO_SINGULAR, the judging schema from the type→Zod registry, and lintability read off each schema's own unknown-key posture (strip → lint; strict → skip, the parse is loud; passthrough → skip, nothing is dropped; unions → member-key union). The walker lives in kernel/ beside the schema registry; the comparator and guidance tables stay in data/, frontend-safe. Root export unchanged, so defineStack, os validate and os build pick the coverage up with no change. Mid-review, #4165 graduated `app` to strict — the derivation adapted on its own, and the pinned coverage expectation turned the shrink into a red test until a human confirmed it was a graduation, not a regression. That is the gate working as designed; the strict roster in the test now records it. Verified clean against the three example apps (zero false positives), by mutation in three directions, and 7133 spec tests green on the merged base.
1 parent 8621cdd commit f6472d7

11 files changed

Lines changed: 521 additions & 168 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the unknown-authoring-key lint covers every metadata collection, not just objects (#3786)
6+
7+
#4148 introduced the lint for `object` and `field` — the two surfaces #4120
8+
caught real drift on. Those two were a sample, not the population: of the
9+
authorable metadata types, only a handful are `.strict()` (`flow` / `permission`
10+
/ `position` / `tool` from #4001 Tier-A, joined mid-review by `app` via #4165).
11+
Every other type strips an
12+
undeclared key exactly the way `field` did — an author who misspells a key on a
13+
`page`, an `agent` or a `dashboard` got the same parse-clean-value-gone silence,
14+
with no lint watching.
15+
16+
`lintUnknownAuthoringKeys` now walks **every metadata collection** — 16 today:
17+
object, page, dashboard, report, dataset, action, job, agent, skill, hook,
18+
mapping, datasource, view, email_template, doc, book — and its coverage is
19+
**derived, not listed**: which collections exist comes from `PLURAL_TO_SINGULAR`
20+
(the same boundary map the normalizer uses), which schema judges each comes from
21+
the canonical type→Zod registry, and whether linting is even meaningful is read
22+
off each schema's own unknown-key posture. A third hand-written "types the lint
23+
covers" list would have been the #3786 shape all over again, inside the tool
24+
built to end it.
25+
26+
The posture rules keep the lint from ever disagreeing with the parse:
27+
28+
- **strip** (zod default) → lint: the parse drops unknown keys silently, and
29+
that silence is what gets reported.
30+
- **strict** → skip: the parse already rejects loudly with the schema's own
31+
tombstone guidance; a second, possibly disagreeing voice helps nobody. This
32+
bucket GROWS as #4001 tiers graduate schemas — `app` graduated (#4165) while
33+
this change was in review, and the derivation adapted without an edit.
34+
- **passthrough** → skip: unknown keys survive the parse, nothing is dropped.
35+
- **unions** (`view`) → the union of member keys; lintable only when a member
36+
strips and none passes unknowns through.
37+
38+
`defineStack`, `os validate` and `os build` pick the wider coverage up with no
39+
code change of their own. Verified against the three first-party example apps
40+
(28 pages, 29 flows, 11 actions and friends in the showcase): all clean, zero
41+
false positives. Verified by mutation: dropping union handling, inverting the
42+
strict filter, and skipping a collection each turn the tests red.
43+
44+
New root/kernel exports: `listLintableAuthoringCollections` (+
45+
`LintableAuthoringCollection`) — the derived coverage as data, so tooling can
46+
report what the evidence base for the #4001 strict tiers actually spans.
47+
48+
One import-site change: `lintUnknownAuthoringKeys` moved from the `/data`
49+
subpath to the package root and `/kernel` (`@objectstack/spec` root import is
50+
unchanged and remains the canonical site). Covering every type means importing
51+
every schema, and `/data` is consumed by frontend bundles — the walker moving
52+
out keeps that chunk from inheriting the whole schema universe. If you imported
53+
it from `@objectstack/spec/data`, import from `@objectstack/spec` instead. The
54+
comparator, guidance tables and finding types stay in `/data`, unchanged.

content/docs/deployment/validating-metadata.mdx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,12 @@ literal (it comes from React state or a variable), a usage carrying a `{...sprea
209209
a chart given static `data` (its columns are the author's own), and objects
210210
another package defines.
211211

212-
### 9. Object and field keys the schema never declared
212+
### 9. Authoring keys the schema never declared
213213

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

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

243+
The check covers **every metadata collection** — pages, apps, agents,
244+
dashboards, views, actions, and the rest — with its coverage derived from the
245+
same collection map the loader uses, so a newly registered collection is
246+
covered the moment it exists. Types that are already strict are skipped: there
247+
the parse itself rejects loudly, with the schema's own guidance. Because the
248+
lint reads each schema's real unknown-key posture, it can never disagree with
249+
the parse.
250+
241251
This is **advisory** — the stack still loads. Strict rejection is where these
242-
schemas are headed (ADR-0049 enforce-or-remove), but they are the two
243-
most-authored surfaces in the protocol, so the tightening is scheduled on what
244-
this check finds rather than assumed. `defineStack` reports the same findings at
245-
config-load time, so an author sees them without running the CLI at all.
252+
schemas are headed (ADR-0049 enforce-or-remove), but these are the protocol's
253+
most-authored surfaces, so the tightening is scheduled on what this check finds
254+
rather than assumed. `defineStack` reports the same findings at config-load
255+
time, so an author sees them without running the CLI at all.
246256

247257
## The one gate, two entry points
248258

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

269279
So `os validate` is the fast inner-loop check (no artifact); `os build` is what

packages/spec/api-surface.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"F (const)",
5353
"FIELD_KEY_GUIDANCE (const)",
5454
"GUEST_POSITION (const)",
55+
"LintableAuthoringCollection (interface)",
5556
"MAP_SUPPORTED_FIELDS (const)",
5657
"MEMBERSHIP_ROLE_ADMIN (const)",
5758
"MEMBERSHIP_ROLE_DELEGATED_ADMIN (const)",
@@ -156,6 +157,7 @@
156157
"isAggregatedViewContainer (function)",
157158
"isKnownPlatformCapability (function)",
158159
"lintUnknownAuthoringKeys (function)",
160+
"listLintableAuthoringCollections (function)",
159161
"mapMembershipRole (function)",
160162
"normalizeMetadataCollection (function)",
161163
"normalizePluginMetadata (function)",
@@ -601,10 +603,11 @@
601603
"isKnownFilterToken (function)",
602604
"isLegacyApiMethod (function)",
603605
"isMultiValueField (function)",
606+
"isPlainRecord (function)",
604607
"isTenancyDisabled (function)",
605608
"isTitleEligible (function)",
606609
"isUniqueDeclared (function)",
607-
"lintUnknownAuthoringKeys (function)",
610+
"lintAuthoredRecordKeys (function)",
608611
"missingFieldValues (function)",
609612
"nextUtcCalendarDay (function)",
610613
"objectForm (const)",
@@ -1517,6 +1520,7 @@
15171520
"KernelSecurityVulnerabilitySchema (const)",
15181521
"KernelShutdownEvent (type)",
15191522
"KernelShutdownEventSchema (const)",
1523+
"LintableAuthoringCollection (interface)",
15201524
"ListPackagesRequest (type)",
15211525
"ListPackagesRequestSchema (const)",
15221526
"ListPackagesResponse (type)",
@@ -1857,6 +1861,8 @@
18571861
"getMetadataTypeSchema (function)",
18581862
"isConsumerInstallable (function)",
18591863
"isKnownPlatformCapability (function)",
1864+
"lintUnknownAuthoringKeys (function)",
1865+
"listLintableAuthoringCollections (function)",
18601866
"listMetadataCreateSeedTypes (function)",
18611867
"listMetadataTypeSchemaTypes (function)",
18621868
"lowerRequiresFeature (function)",

packages/spec/src/data/authoring-key-lint.test.ts

Lines changed: 40 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,118 +1,71 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

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

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

1415
import {
15-
lintUnknownAuthoringKeys,
16+
lintAuthoredRecordKeys,
1617
formatUnknownAuthoringKey,
1718
FIELD_KEY_GUIDANCE,
1819
OBJECT_KEY_GUIDANCE,
20+
type UnknownAuthoringKeyFinding,
1921
} from './authoring-key-lint';
2022
import { ObjectSchema } from './object.zod';
2123
import { FieldSchema } from './field.zod';
2224

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

25-
/** A minimal well-formed stack with one object and one field. */
26-
const stackWith = (obj: Record<string, unknown>, field: Record<string, unknown>) => ({
27-
objects: [
28-
{ name: 'crm_case', label: 'Case', fields: { owner: { label: 'Owner', type: 'text', ...field } }, ...obj },
29-
],
30-
});
31-
32-
describe('lintUnknownAuthoringKeys (#3786)', () => {
33-
it('is silent on a clean stack', () => {
34-
expect(lintUnknownAuthoringKeys(stackWith({}, {}))).toEqual([]);
35-
});
36-
37-
it('reports a field key the schema does not declare', () => {
38-
const [finding, ...rest] = lintUnknownAuthoringKeys(stackWith({}, { pii: true }));
39-
expect(rest).toEqual([]);
40-
expect(finding).toMatchObject({
41-
path: 'objects.crm_case.fields.owner.pii',
42-
surface: 'field',
43-
key: 'pii',
44-
});
45-
// A retired key gets a prescription, not a "did you mean" into a void.
46-
expect(finding.guidance).toBeTruthy();
47-
expect(finding.suggestion).toBeUndefined();
27+
function runComparator(
28+
record: Record<string, unknown>,
29+
declared: readonly string[],
30+
guidance: Readonly<Record<string, { to?: string; why?: string }>> = {},
31+
): UnknownAuthoringKeyFinding[] {
32+
const out: UnknownAuthoringKeyFinding[] = [];
33+
lintAuthoredRecordKeys(record, new Set(declared), guidance, 'field', 'p', out);
34+
return out;
35+
}
36+
37+
describe('lintAuthoredRecordKeys (#3786)', () => {
38+
it('is silent when every key is declared', () => {
39+
expect(runComparator({ a: 1, b: 2 }, ['a', 'b', 'c'])).toEqual([]);
4840
});
4941

50-
it('reports an object key the schema does not declare, with the rename', () => {
51-
const [finding] = lintUnknownAuthoringKeys(stackWith({ capabilities: { trackHistory: true } }, {}));
52-
expect(finding).toMatchObject({
53-
path: 'objects.crm_case.capabilities',
54-
surface: 'object',
55-
key: 'capabilities',
56-
suggestion: 'enable',
57-
});
42+
it('reports an undeclared key with an edit-distance suggestion for a typo', () => {
43+
const [f] = runComparator({ requred: true }, ['required', 'label']);
44+
expect(f).toMatchObject({ path: 'p.requred', key: 'requred', suggestion: 'required' });
5845
});
5946

60-
/**
61-
* The exact set #4120 found rendering in `object.form.ts` while saving
62-
* nothing. If the lint had existed, each of these would have been one warning
63-
* rather than releases of a dead toggle.
64-
*/
65-
const FORM_DRIFT_KEYS = [
66-
'indexed', 'immutable', 'filterable', 'placeholder', 'validation', 'errorMessage',
67-
'audit', 'pii', 'encrypted', 'startingNumber',
68-
'referenceFilter', 'cascadeDelete', 'formula', 'displayFormat', 'summaryType', 'summaryField',
69-
] as const;
70-
71-
it.each(FORM_DRIFT_KEYS)('catches the #4120 drift key %s and says something actionable', (key) => {
72-
const [finding, ...rest] = lintUnknownAuthoringKeys(stackWith({}, { [key]: 'x' }));
73-
expect(finding, `${key} should be reported`).toBeDefined();
74-
expect(rest).toEqual([]);
75-
expect(finding.key).toBe(key);
76-
// Either a rename target or a retirement reason — never a bare "unknown".
77-
expect(
78-
finding.suggestion ?? finding.guidance,
79-
`${key} needs a rename target or a retirement reason`,
80-
).toBeTruthy();
81-
expect(formatUnknownAuthoringKey(finding)).toContain(key);
82-
});
83-
84-
it('falls back to edit distance for a plain typo', () => {
85-
const [finding] = lintUnknownAuthoringKeys(stackWith({}, { requred: true }));
86-
expect(finding.suggestion).toBe('required');
47+
it('a retirement suppresses the edit-distance fallback', () => {
48+
// `pii` is 3 edits from `min` — "did you mean min?" reads as advice while
49+
// being nonsense. A `why` with no `to` must yield guidance and NO suggestion.
50+
const [f] = runComparator({ pii: true }, ['min', 'max'], FIELD_KEY_GUIDANCE);
51+
expect(f.guidance).toBeTruthy();
52+
expect(f.suggestion).toBeUndefined();
8753
});
8854

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

96-
it('reports every offending key, across objects and fields', () => {
97-
const findings = lintUnknownAuthoringKeys({
98-
objects: [
99-
{ name: 'a', label: 'A', capabilities: {}, fields: { x: { type: 'text', pii: true } } },
100-
{ name: 'b', label: 'B', fields: { y: { type: 'text', indexed: true } } },
101-
],
102-
});
103-
expect(findings.map((f) => f.path).sort()).toEqual([
104-
'objects.a.capabilities',
105-
'objects.a.fields.x.pii',
106-
'objects.b.fields.y.indexed',
107-
]);
60+
it('skips the underscore-prefixed packaging channel', () => {
61+
expect(runComparator({ _packageId: 'p', _lock: true, _provenance: 'x' }, ['label'])).toEqual([]);
10862
});
10963

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

0 commit comments

Comments
 (0)