From acfaa141144ee93c02a4879512a5c30a61fbd50c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:51:59 +0000 Subject: [PATCH 1/2] feat(spec,cli): report the authored object/field keys that get silently dropped (#3786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectSchema` and `FieldSchema` are deliberately not `.strict()`, so a key they do not declare parses clean and is stripped on the way to storage. No error, no warning — the author configured something and it simply is not there. That is the ADR-0104 failure class the FieldSchema prune tombstone already describes in prose, and #4120 found five live instances of it inside this package: a `pii` toggle, an `indexed` toggle and a `cascadeDelete` select that had been rendering in Studio for releases while saving nothing. `lintUnknownAuthoringKeys` reports every such key, naming the path and what to do. Two guidance tables carry the difference between a rename (`formula` → `expression`, `capabilities` → `enable`) and a retirement with no successor (`pii`, `indexed`, `encrypted`). A retirement suppresses the edit-distance fallback on purpose: `pii` is three edits from `min`, and "did you mean min?" reads as advice while being nonsense. Plain typos still get it (`requred` → `required`). It never rejects. Strict is the destination — the enforce side of ADR-0049, the tier programme #4001 began on flow and permission — but object and field are the two most-authored surfaces in the protocol, so flipping them is a migration event for every consumer. This produces the evidence to schedule that on, and costs nobody a migration. Wired pre-parse into both layers that perform the discard, since after the parse there is nothing left to report: `defineStack` (console, once per path, strict and non-strict alike) and `os validate` (non-blocking, and in `--json` rather than computed then discarded). Two failures the tests caught on the way in, both kept as regression cover: the edit-distance fallback firing on retired keys, and a guidance entry for `conditionalRequired` — which is still DECLARED as a retiredKey() tombstone, so the schema already rejects it with its own prescription and the lint never reaches it. Verified against app-todo, app-crm and app-showcase: all clean, no false positives. spec 7110 tests green; the 5 remaining `os serve` e2e failures reproduce on a clean tree (they need a fully built workspace). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UXGj3Z5TmwSV6RK2oGc3cb --- .changeset/unknown-authoring-key-lint.md | 59 +++++ packages/cli/src/commands/validate.ts | 22 +- packages/spec/api-surface.json | 12 + .../spec/src/data/authoring-key-lint.test.ts | 160 +++++++++++++ packages/spec/src/data/authoring-key-lint.ts | 220 ++++++++++++++++++ packages/spec/src/data/index.ts | 4 + packages/spec/src/index.ts | 13 ++ packages/spec/src/stack.zod.ts | 30 +++ 8 files changed, 518 insertions(+), 2 deletions(-) create mode 100644 .changeset/unknown-authoring-key-lint.md create mode 100644 packages/spec/src/data/authoring-key-lint.test.ts create mode 100644 packages/spec/src/data/authoring-key-lint.ts diff --git a/.changeset/unknown-authoring-key-lint.md b/.changeset/unknown-authoring-key-lint.md new file mode 100644 index 0000000000..1d49848873 --- /dev/null +++ b/.changeset/unknown-authoring-key-lint.md @@ -0,0 +1,59 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": patch +--- + +feat(spec,cli): report the authored object/field keys that get silently dropped (#3786) + +`ObjectSchema` and `FieldSchema` are deliberately not `.strict()`, so a key they +do not declare **parses clean and is stripped on the way to storage**. No error, +no warning — the author configured something and it simply is not there. That is +the ADR-0104 failure class the `FieldSchema` prune tombstone already describes in +prose, and #4120 found five live instances of it inside `@objectstack/spec` +itself: a `pii` toggle, an `indexed` toggle and a `cascadeDelete` select that had +been rendering in Studio for releases while saving nothing. + +**New rule — `lintUnknownAuthoringKeys` (advisory).** Every authored key an +object or field sets that its schema does not declare is now reported, naming the +path, the key, and what to do about it: + +``` +defineStack: objects.crm_case.fields.owner.pii: 'pii' is not a declared field key, + so its value is dropped at load — the `dataQuality` governance family was pruned + in 2026-06 as dead in both layers — it enforced nothing. +defineStack: objects.crm_case.capabilities: 'capabilities' is not a declared object + key, so its value is dropped at load — did you mean 'enable'? +``` + +Two guidance tables carry the difference between a **rename** (`formula` → +`expression`, `cascadeDelete` → `deleteBehavior`, `capabilities` → `enable`, …) +and a **retirement** with no successor (`pii`, `indexed`, `encrypted`, +`startingNumber`, …). A retirement deliberately suppresses the edit-distance +fallback: `pii` is three edits from `min`, and "did you mean min?" reads as real +advice while being nonsense. Plain typos still get the fallback (`requred` → +`required`). Every entry was found in the wild, and a test asserts each rename +target is a key the schema really declares — so the advice cannot rot into +pointers at keys that no longer exist. + +**It never rejects.** Making these two schemas strict is the destination — the +enforce side of ADR-0049, and the tier programme #4001 began on the flow and +permission schemas. But `object` and `field` are the two most-authored surfaces +in the protocol, so flipping them rejects metadata that parses today: a migration +event for every consumer, and one that deserves to be scheduled on evidence +rather than guessed at. This produces that evidence and costs nobody a migration. + +Wired into the two layers that perform the discard, both **pre-parse** (the parse +is what eats the key, so after it there is nothing left to report): + +- **`defineStack`** — warns on the console, once per distinct path, in strict + *and* non-strict mode, since the key is dropped either way. +- **`os validate`** — a non-blocking warning, and included in `--json` output + rather than computed and discarded. + +Verified against the three first-party example apps (`app-todo`, `app-crm`, +`app-showcase`): all clean, no false positives. + +New exports from `@objectstack/spec` (root and `/data`): `lintUnknownAuthoringKeys`, +`formatUnknownAuthoringKey`, `FIELD_KEY_GUIDANCE`, `OBJECT_KEY_GUIDANCE`, and the +`UnknownAuthoringKeyFinding` / `AuthoringKeySurface` types. No authoring change is +required by this release: metadata that loaded before still loads, unchanged. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index d3b8a7c14a..1add504da4 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -6,7 +6,13 @@ import { createRequire } from 'node:module'; import { join, dirname } from 'node:path'; import chalk from 'chalk'; import { ZodError } from 'zod'; -import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec'; +import { + ObjectStackDefinitionSchema, + normalizeStackInput, + lintUnknownAuthoringKeys, + formatUnknownAuthoringKey, + type ConversionNotice, +} from '@objectstack/spec'; import { loadConfig } from '../utils/config.js'; import { validateStackExpressions } from '@objectstack/lint'; import { validateListViewMode } from '@objectstack/lint'; @@ -85,6 +91,14 @@ export default class Validate extends Command { const normalized = normalizeStackInput(config as Record, { onConversionNotice: (n) => conversionNotices.push(n), }); + // [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and so drop + // silently. PRE-parse for the same reason the visibility rule below is: + // the parse is what strips them, so `result.data` no longer carries the + // key the author actually wrote. Computed here rather than down in the + // warnings section so the `--json` path reports it too — the + // "computed, then discarded" shape this file already had to fix once. + const unknownKeyWarnings = lintUnknownAuthoringKeys(normalized as Record) + .map(formatUnknownAuthoringKey); const result = ObjectStackDefinitionSchema.safeParse(normalized); if (!result.success) { @@ -732,7 +746,7 @@ export default class Validate extends Command { // the suite's warnings, though the failure path (above) and the console // both did. Same shape of bug as the dropped errors — computed, then // discarded — so it is fixed rather than reproduced under a new name. - warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...refWarnings, ...readonlyWriteWarnings, ...authoringLintWarnings, ...securityAdvisories, ...capProviderWarnings], + warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...refWarnings, ...readonlyWriteWarnings, ...authoringLintWarnings, ...unknownKeyWarnings, ...securityAdvisories, ...capProviderWarnings], conversions: conversionNotices, specVersionGap: specGap, duration: timer.elapsed(), @@ -758,6 +772,10 @@ export default class Validate extends Command { warnings.push(`${f.where}: ${f.message} — ${f.hint}`); } + // [#3786] Undeclared object/field keys — computed pre-parse above, + // alongside `normalized`, for the same reason. + warnings.push(...unknownKeyWarnings); + // ADR-0087 D2 conversion notices: the source used a deprecated shape that // was auto-converted at load. No action is required to keep loading, but // the notice steers the author to the canonical key before it retires. diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 292391470e..ebeff658c7 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -5,6 +5,7 @@ "AUDIENCE_ANCHOR_POSITIONS (const)", "Agent (type)", "ApplyConversionsOptions (interface)", + "AuthoringKeySurface (type)", "BUILTIN_IDENTITY_METADATA (const)", "BUILTIN_IDENTITY_NAMES (const)", "BUILTIN_IDENTITY_ORG_ADMIN (const)", @@ -49,6 +50,7 @@ "ExpressionMetaSchema (const)", "ExpressionSchema (const)", "F (const)", + "FIELD_KEY_GUIDANCE (const)", "GUEST_POSITION (const)", "MAP_SUPPORTED_FIELDS (const)", "MEMBERSHIP_ROLE_ADMIN (const)", @@ -69,6 +71,7 @@ "MigrationStep (interface)", "MigrationTodo (interface)", "NormalizeStackInputOptions (interface)", + "OBJECT_KEY_GUIDANCE (const)", "ORGANIZATION_ADMIN (const)", "ORGANIZATION_ADMIN_GRANTS (const)", "ORGANIZATION_ADMIN_NO_BYPASS (const)", @@ -101,6 +104,7 @@ "SurfaceDiff (interface)", "TemplateExpressionInputSchema (const)", "Tool (type)", + "UnknownAuthoringKeyFinding (interface)", "ViewKeyCollision (interface)", "applyConversions (function)", "applyConversionsToFlow (function)", @@ -145,10 +149,12 @@ "expression (function)", "findClosestMatches (function)", "formatSuggestion (function)", + "formatUnknownAuthoringKey (function)", "formatZodError (function)", "formatZodIssue (function)", "isAggregatedViewContainer (function)", "isKnownPlatformCapability (function)", + "lintUnknownAuthoringKeys (function)", "mapMembershipRole (function)", "normalizeMetadataCollection (function)", "normalizePluginMetadata (function)", @@ -182,6 +188,7 @@ "ApiOperation (type)", "ApiOperationSchema (const)", "ApiPrimitive (type)", + "AuthoringKeySurface (type)", "AutonumberToken (type)", "BOOLEAN_VALUE_TYPES (const)", "BaseEngineOptions (type)", @@ -327,6 +334,7 @@ "ExternalTable (type)", "ExternalTableSchema (const)", "FIELD_GROUP_SYSTEM_FIELDS (const)", + "FIELD_KEY_GUIDANCE (const)", "FILE_REFERENCE_TYPES (const)", "FILTER_LOGIC_CASES (const)", "FILTER_LOGIC_ROWS (const)", @@ -420,6 +428,7 @@ "NoSQLTransactionOptionsSchema (const)", "NormalizedFilter (type)", "NormalizedFilterSchema (const)", + "OBJECT_KEY_GUIDANCE (const)", "ObjectAccessConfig (type)", "ObjectAccessConfigSchema (const)", "ObjectCapabilities (type)", @@ -546,6 +555,7 @@ "TransformType (const)", "UniqueScope (type)", "UniqueScopeSchema (const)", + "UnknownAuthoringKeyFinding (interface)", "VALID_AST_OPERATORS (const)", "ValidationRule (type)", "ValidationRuleSchema (const)", @@ -571,6 +581,7 @@ "deriveRecordSurface (function)", "effectiveOperationsArray (function)", "fieldForm (const)", + "formatUnknownAuthoringKey (function)", "hasDynamicTokens (function)", "hookForm (const)", "isApiOperationAllowed (function)", @@ -588,6 +599,7 @@ "isTenancyDisabled (function)", "isTitleEligible (function)", "isUniqueDeclared (function)", + "lintUnknownAuthoringKeys (function)", "missingFieldValues (function)", "nextUtcCalendarDay (function)", "objectForm (const)", diff --git a/packages/spec/src/data/authoring-key-lint.test.ts b/packages/spec/src/data/authoring-key-lint.test.ts new file mode 100644 index 0000000000..ce486416a7 --- /dev/null +++ b/packages/spec/src/data/authoring-key-lint.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Tests for the unknown-authoring-key lint (#3786). + * + * 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. + */ + +import { describe, it, expect } from 'vitest'; + +import { + lintUnknownAuthoringKeys, + formatUnknownAuthoringKey, + FIELD_KEY_GUIDANCE, + OBJECT_KEY_GUIDANCE, +} from './authoring-key-lint'; +import { ObjectSchema } from './object.zod'; +import { FieldSchema } from './field.zod'; + +const shapeKeys = (s: unknown) => Object.keys((s as { shape: Record }).shape); + +/** A minimal well-formed stack with one object and one field. */ +const stackWith = (obj: Record, field: Record) => ({ + 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(); + }); + + 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', + }); + }); + + /** + * 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('ignores the underscore-prefixed packaging channel', () => { + const findings = lintUnknownAuthoringKeys( + stackWith({ _packageId: 'p', _lock: true }, { _provenance: 'x' }), + ); + expect(findings).toEqual([]); + }); + + 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('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([]); + }); +}); + +describe('the guidance tables do not rot', () => { + it('every FIELD_KEY_GUIDANCE `to` names a key FieldSchema really declares', () => { + const declared = new Set(shapeKeys(FieldSchema)); + for (const [key, hint] of Object.entries(FIELD_KEY_GUIDANCE)) { + if (hint.to) expect(declared, `FIELD_KEY_GUIDANCE.${key} → '${hint.to}'`).toContain(hint.to); + } + }); + + it('every OBJECT_KEY_GUIDANCE `to` names a key ObjectSchema really declares', () => { + const declared = new Set(shapeKeys(ObjectSchema)); + for (const [key, hint] of Object.entries(OBJECT_KEY_GUIDANCE)) { + if (hint.to) expect(declared, `OBJECT_KEY_GUIDANCE.${key} → '${hint.to}'`).toContain(hint.to); + } + }); + + it('no guidance entry names a key the schema now declares itself', () => { + // If a "retired" key came back, its entry is actively wrong — the lint would + // be telling an author to delete something the schema accepts. + const fieldDeclared = new Set(shapeKeys(FieldSchema)); + for (const key of Object.keys(FIELD_KEY_GUIDANCE)) { + expect(fieldDeclared, `FIELD_KEY_GUIDANCE has an entry for the LIVE key '${key}'`).not.toContain(key); + } + const objectDeclared = new Set(shapeKeys(ObjectSchema)); + for (const key of Object.keys(OBJECT_KEY_GUIDANCE)) { + expect(objectDeclared, `OBJECT_KEY_GUIDANCE has an entry for the LIVE key '${key}'`).not.toContain(key); + } + }); + + it('every entry carries either a rename target or a reason, and the tables are not empty', () => { + expect(Object.keys(FIELD_KEY_GUIDANCE).length).toBeGreaterThan(0); + expect(Object.keys(OBJECT_KEY_GUIDANCE).length).toBeGreaterThan(0); + for (const [table, name] of [ + [FIELD_KEY_GUIDANCE, 'FIELD_KEY_GUIDANCE'], + [OBJECT_KEY_GUIDANCE, 'OBJECT_KEY_GUIDANCE'], + ] as const) { + for (const [key, hint] of Object.entries(table)) { + expect(hint.to ?? hint.why, `${name}.${key} needs a 'to' or a 'why'`).toBeTruthy(); + if (hint.why) expect(hint.why.length, `${name}.${key} reason too short`).toBeGreaterThan(30); + } + } + }); +}); diff --git a/packages/spec/src/data/authoring-key-lint.ts b/packages/spec/src/data/authoring-key-lint.ts new file mode 100644 index 0000000000..f002f80baa --- /dev/null +++ b/packages/spec/src/data/authoring-key-lint.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **Unknown authoring keys, reported instead of swallowed** (#3786). + * + * `ObjectSchema` and `FieldSchema` are deliberately not `.strict()`, so a key + * they do not declare parses clean and is **stripped on the way to storage**. + * The author gets no error; the capability they thought they configured simply + * is not there. That is the ADR-0104 failure class the `FieldSchema` prune + * tombstone already describes in prose, and #4120 found five live instances of + * it inside this very package — a `pii` toggle, an `indexed` toggle and a + * `cascadeDelete` select that had been rendering in Studio for releases while + * saving nothing. + * + * ## Why a lint and not `.strict()` + * + * Making these two schemas strict is the eventual destination — it is the + * enforce side of ADR-0049 and the tier programme #4001 started on the flow and + * permission schemas. But `object` and `field` are the two most-authored + * surfaces in the protocol, so flipping them rejects metadata that parses today: + * a migration event for every consumer, and one that should be scheduled on + * evidence rather than guessed at. This rule produces that evidence while + * costing nobody a migration — it reports, it never rejects. + * + * ## Why it lives in `@objectstack/spec` and not `@objectstack/lint` + * + * `@objectstack/lint`'s rules are `(stack) => Finding[]` over a **schema-parsed** + * stack. By then the unknown keys are gone — the parse is what ate them. This + * has to run **pre-parse**, on the authored input, which is the same seam the + * ADR-0087 D2 conversion notices already use in `defineStack`. + * + * @see ADR-0049 enforce-or-remove · ADR-0104 · #4001 (the strict tiers) + */ + +import { findClosestMatches } from '../shared/suggestions.zod'; +import { ObjectSchema } from './object.zod'; +import { FieldSchema } from './field.zod'; + +/** Which authoring surface a finding came from. */ +export type AuthoringKeySurface = 'object' | 'field'; + +/** One unknown key on one authored object or field. */ +export interface UnknownAuthoringKeyFinding { + /** Dotted path to the offending key, e.g. `objects.crm_case.fields.owner.pii`. */ + path: string; + surface: AuthoringKeySurface; + /** The key the schema does not declare. */ + key: string; + /** + * The canonical key this is a recognisable spelling of, when one is known. + * Comes from {@link FIELD_KEY_GUIDANCE} / {@link OBJECT_KEY_GUIDANCE} first, + * then from an edit-distance match against the declared keys. + */ + suggestion?: string; + /** + * Prescriptive sentence for a key that was RETIRED rather than renamed — + * where "did you mean X" would be wrong because there is no X. + */ + guidance?: string; +} + +/** + * Semantic near-misses on `FieldSchema` — a different **word** for the same + * intent, or a key that was retired outright. + * + * Every entry here was found in the wild. The rename half is what `object.form.ts` + * had drifted into by #4120; the retired half is the `auditTrail` / `dataQuality` + * / `encryptionConfig` family pruned in 2026-06 plus the field-level `index` flag + * removed in the 16.x line (#2377). Edit distance cannot reach most of these + * (`cascadeDelete` → `deleteBehavior` is 11 apart), which is exactly why they are + * named rather than left to the fallback. + * + * A `to` names a declared `FieldSchema` key; a `why` marks a retirement with no + * successor. `authoring-key-lint.test.ts` asserts every `to` really is declared, + * so this table cannot rot into advice pointing at keys that no longer exist. + */ +export const FIELD_KEY_GUIDANCE: Readonly< + Record +> = Object.freeze({ + // ── Renamed: the concept survives under a different key ── + referenceFilter: { to: 'lookupFilters' }, + cascadeDelete: { to: 'deleteBehavior' }, + formula: { to: 'expression' }, + displayFormat: { to: 'autonumberFormat' }, + summaryType: { to: 'summaryOperations' }, + summaryField: { to: 'summaryOperations' }, + // NOTE: no entry for `conditionalRequired`. It was removed as an alias in + // protocol 17 (#3855) but is still DECLARED on FieldSchema as a `retiredKey()` + // tombstone, so the schema rejects it with its own prescription. An entry here + // would be dead weight the lint never reaches — the test below enforces that. + index: { why: 'field-level index flags built no index and were removed in the 16.x line (#2377, ADR-0049) — declare the index in the object\'s `indexes[]` instead.' }, + + // ── Retired: no successor key ── + indexed: { why: 'never a FieldSchema key; a field-level index flag built no index (#2377). Declare the index in the object\'s `indexes[]`.' }, + immutable: { why: 'never a FieldSchema key. Use the `readonlyWhen` predicate to lock a field after creation.' }, + filterable: { why: 'never a FieldSchema key — every declared column is filterable. `sortable` and `searchable` are the real knobs.' }, + placeholder: { why: 'never a FieldSchema key. Author hint text through `inlineHelpText` or `description`.' }, + startingNumber: { why: 'never a FieldSchema key. An autonumber counter resets per rendered prefix, which `autonumberFormat` itself determines.' }, + validation: { why: 'field-level predicates are not a FieldSchema key — author a `validation` metadata item on the object, which carries its own message.' }, + errorMessage: { why: 'pairs with the `validation` key that never existed; a `validation` metadata item carries its own message.' }, + audit: { why: 'the `auditTrail` family was pruned in 2026-06 as dead in both layers. Use `trackHistory` for the activity timeline.' }, + auditTrail: { why: 'pruned in 2026-06 as dead in both layers. Use `trackHistory` for the activity timeline.' }, + pii: { why: 'the `dataQuality` governance family was pruned in 2026-06 as dead in both layers — it enforced nothing.' }, + dataQuality: { why: 'pruned in 2026-06 as dead in both layers (#3726) — it enforced nothing.' }, + encrypted: { why: 'the `encryptionConfig` family was pruned in 2026-06: it implied at-rest protection that never happened. The real channel is `type: \'secret\'`.' }, + encryptionConfig: { why: 'pruned in 2026-06 — it implied at-rest protection that never happened. The real channel is `type: \'secret\'`.' }, + maskingRule: { why: 'pruned in 2026-06 as dead in both layers — masking was never applied.' }, + cached: { why: 'computed-field caching was pruned in 2026-06 (#3733); nothing read it.' }, +}); + +/** + * Semantic near-misses on `ObjectSchema`. Smaller than the field table because + * the object surface has drifted less — `capabilities` is the one #4120 caught, + * and it had silenced an entire seven-toggle section of the metadata form. + */ +export const OBJECT_KEY_GUIDANCE: Readonly< + Record +> = Object.freeze({ + capabilities: { to: 'enable' }, + features: { to: 'enable' }, + namespace: { why: 'deprecated and removed — the object `name` is the canonical id everywhere. For module grouping embed a prefix in the name (`sys_user`).' }, + tableName: { why: 'removed — the table name always equals the object `name`.' }, +}); + +/** Declared top-level keys of a Zod object schema, read off `.shape`. */ +function declaredKeys(schema: unknown): readonly string[] { + const shape = (schema as { shape?: Record }).shape; + return shape ? Object.keys(shape) : []; +} + +function isPlainRecord(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** + * Compare one authored record's keys against a declared key set. + * + * Keys starting with `_` are skipped: they are the packaging/provenance channel + * (`_packageId`, `_lock`, `_provenance`) that tooling stamps onto artifacts, and + * a stray one is a tooling concern, not an authoring mistake. + */ +function lintRecord( + record: Record, + declared: readonly string[], + guidance: Readonly>, + surface: AuthoringKeySurface, + basePath: string, + out: UnknownAuthoringKeyFinding[], +): void { + const known = new Set(declared); + for (const key of Object.keys(record)) { + if (known.has(key) || key.startsWith('_')) continue; + + const hint = guidance[key]; + // A retirement (`why`, no `to`) deliberately suppresses the edit-distance + // fallback: there IS no successor, and the nearest declared key by spelling + // is noise — `pii` is 3 edits from `min`, which would read as real advice. + const suggestion = hint?.to ?? (hint?.why ? undefined : findClosestMatches(key, declared, 3, 1)[0]); + out.push({ + path: `${basePath}.${key}`, + surface, + key, + ...(suggestion ? { suggestion } : {}), + ...(hint?.why ? { guidance: hint.why } : {}), + }); + } +} + +/** + * Report every key an authored stack sets on an object or field that the schema + * does not declare — i.e. every value the parse is about to discard silently. + * + * Pure and side-effect free. Runs on the **raw** (normalized but unparsed) + * stack: see the module note for why a parsed stack is too late. + * + * @param rawStack The authored stack, after `normalizeStackInput` and before + * `ObjectStackDefinitionSchema.parse`. + */ +export function lintUnknownAuthoringKeys(rawStack: unknown): UnknownAuthoringKeyFinding[] { + if (!isPlainRecord(rawStack)) return []; + const objects = rawStack.objects; + if (!Array.isArray(objects)) return []; + + const objectKeys = declaredKeys(ObjectSchema); + const fieldKeys = declaredKeys(FieldSchema); + // A schema that failed to expose a shape would make this rule silently pass on + // everything — the failure mode a lint must not have. + if (objectKeys.length === 0 || fieldKeys.length === 0) return []; + + const out: UnknownAuthoringKeyFinding[] = []; + for (const obj of objects) { + if (!isPlainRecord(obj)) continue; + const objName = typeof obj.name === 'string' && obj.name ? obj.name : ''; + const objPath = `objects.${objName}`; + lintRecord(obj, objectKeys, OBJECT_KEY_GUIDANCE, 'object', objPath, out); + + const fields = obj.fields; + if (!isPlainRecord(fields)) continue; + for (const [fieldName, field] of Object.entries(fields)) { + if (!isPlainRecord(field)) continue; + lintRecord( + field, + fieldKeys, + FIELD_KEY_GUIDANCE, + 'field', + `${objPath}.fields.${fieldName}`, + out, + ); + } + } + return out; +} + +/** One human-readable line for a finding — shared by `defineStack` and the CLI. */ +export function formatUnknownAuthoringKey(f: UnknownAuthoringKeyFinding): string { + const head = `${f.path}: '${f.key}' is not a declared ${f.surface} key, so its value is dropped at load`; + if (f.guidance) return `${head} — ${f.guidance}`; + if (f.suggestion) return `${head} — did you mean '${f.suggestion}'?`; + return `${head}.`; +} diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index e1c5389172..299d03fa28 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -18,6 +18,10 @@ export * from './object.zod'; // `enable.apiMethods` whitelist into its effective operation set (#3391). export * from './api-derivation'; export * from './field.zod'; +// Pre-parse report of authored keys ObjectSchema/FieldSchema would strip (#3786). +// Neither schema is `.strict()`, so an undeclared key parses clean and is dropped; +// this is what turns that silence into a warning the author can act on. +export * from './authoring-key-lint'; // Field runtime value-shape contract (ADR-0104 D1) export * from './field-value.zod'; export * from './autonumber-format'; diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 326be1389e..1897da7f06 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -91,6 +91,19 @@ export { defineEmailTemplateDefinition } from './system/email-template.zod'; export { defineReport } from './ui/report.zod'; export { defineWebhook } from './automation/webhook.zod'; export { defineObjectExtension } from './data/object.zod'; +// Pre-parse report of authored object/field keys the schemas would strip (#3786). +// Exported from the ROOT, not only from `/data`: `defineStack` (this package) and +// the CLI's `os validate` are the two callers, and both import from the root. +export { + lintUnknownAuthoringKeys, + formatUnknownAuthoringKey, + FIELD_KEY_GUIDANCE, + OBJECT_KEY_GUIDANCE, +} from './data/authoring-key-lint'; +export type { + UnknownAuthoringKeyFinding, + AuthoringKeySurface, +} from './data/authoring-key-lint'; export { defineCube } from './data/analytics.zod'; export { defineMapping } from './data/mapping.zod'; export { defineTheme } from './ui/theme.zod'; diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 22830d5414..b670673500 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -11,6 +11,10 @@ import { hasPlatformObjectPrefix } from './system/constants/platform-object-name import { objectStackErrorMap, formatZodError } from './shared/error-map.zod'; import { normalizeStackInput, type MetadataCollectionInput, type MapSupportedField } from './shared/metadata-collection.zod'; import type { ConversionNotice } from './conversions/types.js'; +import { + lintUnknownAuthoringKeys, + formatUnknownAuthoringKey, +} from './data/authoring-key-lint'; // Data Protocol import { ObjectSchema, ObjectExtensionSchema } from './data/object.zod'; @@ -1090,6 +1094,28 @@ function warnConversionNotice(notice: ConversionNotice): void { ); } +const warnedUnknownAuthoringKeys = new Set(); + +/** + * Surface every authored key `ObjectSchema` / `FieldSchema` is about to discard + * (#3786). + * + * Same seam and same posture as {@link warnConversionNotice}: it runs pre-parse, + * because the parse is what eats the key, and it only WARNS — these two schemas + * are the most-authored surfaces in the protocol, so rejecting is a scheduled + * migration (#4001's strict tiers), not something to slip in behind a lint. + * + * Runs in both strict and non-strict mode: the key is dropped either way, so the + * author deserves to hear about it either way. + */ +function warnUnknownAuthoringKeys(raw: unknown): void { + for (const finding of lintUnknownAuthoringKeys(raw)) { + if (warnedUnknownAuthoringKeys.has(finding.path)) continue; + warnedUnknownAuthoringKeys.add(finding.path); + console.warn(`defineStack: ${formatUnknownAuthoringKey(finding)}`); + } +} + export function defineStack( config: ObjectStackDefinitionInput, options?: DefineStackOptions, @@ -1106,6 +1132,10 @@ export function defineStack( onConversionNotice: warnConversionNotice, }); + // Pre-parse: the parse below is what strips an undeclared key, so this is the + // last point at which the author's own spelling still exists to report (#3786). + warnUnknownAuthoringKeys(normalized); + if (!strict) { // Non-strict mode: skip validation (advanced use cases only). return mergeActionsIntoObjects(normalized as ObjectStackDefinition); From 9573d476fd4eab043b75a9c54acad35144f10e15 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:59:21 +0000 Subject: [PATCH 2/2] feat(cli,docs): the build path reports undeclared keys too, and the gate table says so (#3786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the lint's first commit, closing a gap the docs-drift check made visible: `validating-metadata.mdx` is a catalog of what `os validate`/`os build` report, and the new check was not in it. Writing the row surfaced the real omission underneath — the check ran in `validate.ts` only. `defineStack` already covers any config authored through it, whichever command loads it. What it does not cover is a config that SKIPS it — a plain object default-export, or `strict: false` — and on that path `os build` would emit an artifact with the key silently gone. `compile.ts` now runs the same pre-parse report, at the same seam as the ADR-0089 visibility rule beside it. Verified: a plain-object config with `pii` + `indexed` now warns on the build path. Not a parity-test violation either way — `validate-build-gate-parity.test.ts` enforces compile ⊆ validate, and validate already had it. This makes the two agree in the direction the test cannot see. Docs: a row in the two-entry-point table, and a §9 section showing what the finding looks like and why a retired key deliberately gets no "did you mean". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UXGj3Z5TmwSV6RK2oGc3cb --- .changeset/unknown-authoring-key-lint.md | 8 +++-- .../docs/deployment/validating-metadata.mdx | 36 +++++++++++++++++++ packages/cli/src/commands/compile.ts | 22 +++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/.changeset/unknown-authoring-key-lint.md b/.changeset/unknown-authoring-key-lint.md index 1d49848873..e6afe892f1 100644 --- a/.changeset/unknown-authoring-key-lint.md +++ b/.changeset/unknown-authoring-key-lint.md @@ -42,13 +42,17 @@ in the protocol, so flipping them rejects metadata that parses today: a migratio event for every consumer, and one that deserves to be scheduled on evidence rather than guessed at. This produces that evidence and costs nobody a migration. -Wired into the two layers that perform the discard, both **pre-parse** (the parse -is what eats the key, so after it there is nothing left to report): +Wired into every layer that performs the discard, all **pre-parse** (the parse is +what eats the key, so after it there is nothing left to report): - **`defineStack`** — warns on the console, once per distinct path, in strict *and* non-strict mode, since the key is dropped either way. - **`os validate`** — a non-blocking warning, and included in `--json` output rather than computed and discarded. +- **`os build` / `os compile`** — the same non-blocking warning. `defineStack` + already covers configs authored through it; this catches the ones that skip it + (a plain object default-export, `strict: false`), which would otherwise emit an + artifact with the key quietly gone. Verified against the three first-party example apps (`app-todo`, `app-crm`, `app-showcase`): all clean, no false positives. diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 6e2a5dc56a..49dceef4d6 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -209,6 +209,41 @@ 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 + +`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; +the setting simply is not there. + +```ts +fields: { + ssn: { label: 'SSN', type: 'text', pii: true, indexed: true }, + // ↑ neither is a FieldSchema key → both dropped +} +``` + +Each one is reported with what to do about it — a rename where the concept +survives under another key, or the reason it was retired where it does not: + +``` +objects.employee.fields.ssn.pii: 'pii' is not a declared field key, so its value + is dropped at load — the `dataQuality` governance family was pruned in 2026-06 + as dead in both layers — it enforced nothing. +objects.employee.fields.ssn.indexed: 'indexed' is not a declared field key, so its + value is dropped at load — never a FieldSchema key; a field-level index flag + built no index (#2377). Declare the index in the object's `indexes[]`. +``` + +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. + +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. + ## The one gate, two entry points `os validate` and `os build` (alias of `os compile`) run the **same** validator: @@ -228,6 +263,7 @@ another package defines. | View references — form targets, view-key collisions (#2554) | ✓ | ✓ | | Flow authoring anti-patterns (#1874) | ✓ | ✓ | | Liveness author-warnings | ✓ | ✓ | +| Undeclared object/field keys (#3786) | ✓ | ✓ | | Emits `dist/objectstack.json` | — | ✓ | So `os validate` is the fast inner-loop check (no artifact); `os build` is what diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 7bd47d3730..a85a1285fc 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -5,7 +5,13 @@ import path from 'path'; import fs from 'fs'; import chalk from 'chalk'; import { ZodError } from 'zod'; -import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice } from '@objectstack/spec'; +import { + ObjectStackDefinitionSchema, + normalizeStackInput, + lintUnknownAuthoringKeys, + formatUnknownAuthoringKey, + type ConversionNotice, +} from '@objectstack/spec'; import { loadConfig } from '../utils/config.js'; import { lowerCallables } from '../utils/lower-callables.js'; import { validateStackExpressions } from '@objectstack/lint'; @@ -251,6 +257,20 @@ export default class Compile extends Command { } } + // 3b-ter. [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and + // so drop silently on the way to storage. PRE-parse for the same + // reason as the rule above. `defineStack` already warns for configs + // authored through it; this covers the ones that skip it (a plain + // object default-export, `strict: false`) and would otherwise emit an + // artifact with the key quietly gone. Advisory, never fatal. + const unknownKeyFindings = lintUnknownAuthoringKeys(normalized as Record); + if (unknownKeyFindings.length > 0 && !flags.json) { + printWarning(`Undeclared authoring keys (${unknownKeyFindings.length}) — dropped at load (#3786)`); + for (const f of unknownKeyFindings.slice(0, 50)) { + console.log(` • ${formatUnknownAuthoringKey(f)}`); + } + } + // 3c. Widget-binding diagnostics (issues #1719/#1721) — semantic checks // that need the widget's `dataset` reference resolved to its dataset // and `dimensions`/`values` resolved to declared names. Errors are